research/a-runbook-for-a-one-person-operation
A Runbook for a One-Person Operation: What to Write Down First
Engineering Note · November 2, 2026 · 8 minute read · Operations · Documentation · Cloudflare
A runbook for a one-person operation is a short document of exact procedures, written for a reader who remembers nothing, covering the tasks that are rare, consequential and easy to get subtly wrong. A small studio has no operations team, so the operations live in one person’s head. That works until the day something breaks at an inconvenient hour and the person who understood it has to reconstruct it from memory. Ours is a single file in the repository called OPERATIONS.md, and we wrote it for a specific reader: whoever is on call at 2 a.m. and has forgotten everything. Most of the time that reader is us, six months later.
How do you write a runbook for the person who forgot?
That reader assumption changes how every section is written. It means no “as you know,” no shorthand for the dashboard you always use, and no step that depends on remembering which of two similarly named settings is the right one. Each section says where to click, what to change, and how to confirm it worked. If a step can be checked, the section says how to check it.
What belongs in a runbook first?
The sections are numbered, and the list is a good picture of where a small, form-driven site can actually go wrong:
- Rotating the email API key.
- Regenerating the Turnstile keys after a suspected compromise.
- Verifying environment variables after every deploy.
- An end-to-end smoke test.
- Adding a form without breaking the contract.
- Database setup and migrations.
- Attachment storage setup.
- Admin dashboard access setup, behind Cloudflare Access.
Notice what these have in common. None of them is a procedure you perform often, and every one is something you would otherwise work out under pressure. Rotating a key is simple when you know the order of operations and miserable when you are guessing. The runbook exists for the tasks that are rare, consequential, and easy to get subtly wrong.
Why check environment variables after every deploy?
Of everything in the file, the environment variable section has earned its place most clearly. The rule is that variables must exist in both Production and Preview, and the check is to verify them after every deploy. It is written as a deploy habit, not a recovery step, because a missing variable usually does not announce itself. The page renders, the form appears, and the failure only shows up when someone submits it.
How do you smoke-test a deploy without any secrets?
Some verification should be possible without handing anyone credentials. Ours is a single request: call a POST-only endpoint with GET and expect HTTP 405 with a JSON error. That one response tells you the function is deployed and routing, which is most of what you need to know first. If you get a 405 with JSON, the problem is further in. If you get anything else, such as an HTML error page, the function is probably not deployed or not routing. It costs nothing, needs no keys, and anyone can run it.
Because the check is a decision, not a number, it is worth scripting so the 2 a.m. reader does not have to interpret headers. Here is a version in plain Node, with no dependencies. The path /api/contact is a stand-in for whatever POST-only route your site has. This is an illustrative script written for this note, run here against local mock servers, not against our production site.
// smoke.mjs <base-url> [path] - credential-free check: GET a POST-only route and classify the answer.
const base = process.argv[2];
const path = process.argv[3] || '/api/contact';
let verdict, code = 1;
try {
const res = await fetch(base + path, { method: 'GET', signal: AbortSignal.timeout(5000) });
const type = res.headers.get('content-type') || '';
const body = await res.text();
let json = null; try { json = JSON.parse(body); } catch {}
const line = `GET ${path} -> ${res.status} ${type.split(';')[0] || '(no content-type)'}`;
if (res.status === 405 && json && json.error) { verdict = 'HEALTHY: function is deployed and routing; the problem is further in'; code = 0; }
else if (res.status >= 500 && json) verdict = 'FUNCTION RUNNING BUT FAILING: read the deploy logs';
else if (type.includes('text/html')) verdict = 'HTML, not JSON: function probably not deployed or not routing';
else verdict = 'UNEXPECTED: ' + body.slice(0, 80);
console.log(line); console.log(verdict);
} catch (e) { console.log(`GET ${path} -> no response (${e.cause?.code || e.name})`); console.log('UNREACHABLE: wrong URL, DNS, or site down'); }
process.exitCode = code;
To test the branches without breaking anything, we wrote a mock server with four modes: a healthy POST-only route, a function that is not deployed, a home-page fallback that answers everything with 200, and a function that is deployed but failing.
// mock.mjs <mode> <port> - modes: healthy | notrouted | spa | crashing
import http from 'node:http';
const [mode, port] = [process.argv[2], Number(process.argv[3])];
const html = (res, status, body) => { res.writeHead(status, { 'content-type': 'text/html' }); res.end(body); };
const json = (res, status, body, h = {}) => { res.writeHead(status, { 'content-type': 'application/json', ...h }); res.end(JSON.stringify(body)); };
http.createServer((req, res) => {
if (mode === 'spa') return html(res, 200, '<html>home</html>');
if (req.url === '/api/contact' && mode === 'healthy' && req.method === 'GET') return json(res, 405, { error: 'Method not allowed' }, { allow: 'POST' });
if (req.url === '/api/contact' && mode === 'crashing') return json(res, 500, { error: 'Internal error' });
html(res, 404, '<html>Not Found</html>');
}).listen(port);
Started on ports 8801 to 8804 (Node 22), with a fifth port nothing listens on, the script printed this:
$ node smoke.mjs http://127.0.0.1:8801 (healthy)
GET /api/contact -> 405 application/json
HEALTHY: function is deployed and routing; the problem is further in
exit=0
$ node smoke.mjs http://127.0.0.1:8802 (notrouted)
GET /api/contact -> 404 text/html
HTML, not JSON: function probably not deployed or not routing
exit=1
$ node smoke.mjs http://127.0.0.1:8803 (spa)
GET /api/contact -> 200 text/html
HTML, not JSON: function probably not deployed or not routing
exit=1
$ node smoke.mjs http://127.0.0.1:8804 (crashing)
GET /api/contact -> 500 application/json
FUNCTION RUNNING BUT FAILING: read the deploy logs
exit=1
$ node smoke.mjs http://127.0.0.1:8899 (nothing listening)
GET /api/contact -> no response (ECONNREFUSED)
UNREACHABLE: wrong URL, DNS, or site down
exit=1
The branches as a table:
| Response to GET | Meaning | Exit code |
|---|---|---|
405 with a JSON error | Deployed and routing; look further in | 0 |
| HTML, any status (404 or a 200 fallback) | Probably not deployed or not routing | 1 |
| 5xx with JSON | Function runs but fails; read the logs | 1 |
| No response | Wrong URL, DNS, or the site is down | 1 |
What can go wrong with the smoke test itself?
Two cases from the run above deserve attention.
A 200 is not a pass. The home-page fallback answered the POST-only route with 200 text/html. A check that only looks for “status is below 400” would call that healthy, and the function would not exist. This is why the check demands a 405 and a JSON body together, and why an HTML answer is the failure signal.
A 404 is not the same as a missing site. The not-deployed mock returned 404 text/html, which looks like a routing failure, but the server was up. When nothing answers at all, the script says ECONNREFUSED, which points at the URL or the host, a different part of the runbook.
What does a symptom map look like?
We also keep a short map from symptoms to likely causes, because at 2 a.m. you start from what you can see, not from what is wrong. Two entries show the shape:
- Submissions failing with a generic error usually mean the email key is missing or invalid, or the sender domain lost its verification.
- “Verification failed” shown to real users usually means the Turnstile site key and secret key do not match.
Each entry points back to the numbered section that fixes it. The map does not try to be exhaustive. It covers the failures we think are most likely, and it is cheap to add a row whenever something new bites us. The smoke test adds the first column of the map: before you decide which section to open, run it, and let the output tell you whether you are looking at a deployment problem or a configuration problem. If you are building something similar, our notes on agents that fail loudly make the related point that a clear error message is itself documentation.
Where do secrets live?
Secrets stay in the hosting dashboard and never go in the repository. The runbook can therefore say exactly where each value lives without containing any of them. Identifiers that are not secret, such as a D1 database ID, can be committed, which makes the setup sections concrete instead of vague. The line between the two is worth stating in the document itself, so nobody has to rediscover it.
How do you keep a runbook alive?
A runbook that is wrong is worse than none, so the habit that matters is updating it in the same change as the thing it describes. When we add a form, the section on adding forms is the checklist, and if the checklist is missing a step we notice because we are following it. That is the best test we have found: use the document to do the real work. Our case for boring web architecture explains why the stack stays small enough for one file to cover it.
Where should you start?
If you have nothing written down, start with three things: how to rotate each secret, how to confirm a deploy is healthy without any credentials, and a symptom map with two or three rows. Write them for the version of you who has forgotten everything, and add sections only when a real incident or a real setup teaches you something worth keeping.
Related reading
Frequently asked questions
What should go in a runbook for a one-person operation?
Start with the rare, consequential procedures: how to rotate each secret, how to confirm a deploy is healthy without any credentials, and a symptom map with two or three rows. Write each section for someone who has forgotten everything, saying where to click, what to change and how to confirm it worked. Add sections only when a real incident teaches something.
How do you smoke-test a deployed serverless function without any secrets?
Send a GET to a POST-only endpoint and read the response. A 405 with a JSON error body means the function is deployed and routing, so any problem is further in. An HTML page, whether a 404 or a 200 fallback, suggests the function is not deployed or not routing. It needs no keys, so anyone can run it.
Why write a runbook for the person who forgot everything?
Because that person is usually you, months later, at an inconvenient hour. Writing for them removes shorthand, assumed dashboards and steps that depend on remembering which of two similar settings is correct. Each step says where to click, what to change and how to confirm it worked, which also makes the runbook usable by someone new.
How do you keep a runbook from going stale?
Update it in the same change as the thing it describes, and use it to do the real work. When we add a form, the section on adding forms is the checklist, so a missing step is noticed because we are following the document. A runbook that is wrong is worse than none, so the test is using it.
Where should secrets live relative to the runbook?
Secrets stay in the hosting dashboard and never in the repository, so the runbook can say exactly where each value lives without containing any of them. Identifiers that are not secret, such as a database ID, can be committed, which keeps the setup sections concrete. State that dividing line in the document itself.