{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"staging-and-preview","title":"The copy you are allowed to break","url":"https://addaly.com/learn/cloud-and-shipping/staging-and-preview","position":17,"of":74,"minutes":8,"answer":"A staging environment is worth having and will still lie to you, so make it cheap, make it disposable, and put a wall between it and anything that can reach a real person.","keyIdea":"A staging environment is worth having and will still lie to you, so make it cheap, make it disposable, and put a wall between it and anything that can reach a real person."},"body":{"format":"markdown","text":"## Why a second environment at all\n\nSo that the first time code meets a real deployment is not the first time real users meet it. That is the whole argument, and it is a good one, but be clear about what a second environment can and cannot tell you.\n\n**It can tell you:** the build works, the migration applies, the config is complete, the pages render, the third-party integration is wired up.\n\n**It cannot tell you:** how it behaves under real traffic, with real data volumes, with real cache states, with real users doing things nobody designed for. Staging has ten rows where production has ten million, so the query that takes 3ms there takes nine seconds live. Staging has one user, so you never see the lock contention.\n\nKnowing which list you are in stops you from over-trusting a green staging run.\n\n## Preview environments beat one shared staging\n\nThe older pattern is a single long-lived staging box that everybody shares. It has a predictable failure: two people's changes are on it at once, something breaks, and nobody knows whose.\n\nThe better pattern is **a fresh environment per branch or pull request**, created automatically and destroyed on merge. Every managed platform does this now, on free tiers. Each pull request gets a URL you can send to somebody.\n\nThe database is the awkward part, and there are three honest options:\n\n- Seed a small fixture dataset on creation. Fast, safe, unrealistic.\n- Use a database that supports cheap branching: Neon's branches are copy-on-write and take about a second.\n- Restore an anonymised production dump nightly. Most realistic, most work, and the anonymisation must be real.\n\n## The rule that matters more than any of this\n\n**Nothing in a non-production environment may reach a real person.**\n\nThe classic incident is not hypothetical and it happens somewhere every month: a test run in staging pointed at the production mail credentials, and forty thousand customers get an email that says `Hello {{first_name}}`. It goes out in under a minute. There is no recall.\n\nThe controls are boring and they work:\n\n- Non-production uses a mail catcher: **Mailpit** or **MailHog**, both free, both a single binary. Every email is captured and viewable in a web UI, and nothing leaves the machine.\n- If you must use a real provider, use its sandbox mode, or enforce an allowlist of recipient domains at the sending layer, refusing anything else.\n- The same rule for SMS, push notifications and webhooks out. A staging webhook hitting a partner's live endpoint is the same incident wearing different clothes.\n\nAnd the sharper version: **staging must never hold production credentials for anything that writes.** Not the mail provider, not the payment processor, not the production database. If staging can write to production, staging is production with a friendlier name and worse discipline.\n\n## Anonymise properly or do not copy at all\n\nIf you restore a production dump, transform it: replace emails with `user+<id>@example.invalid`, blank phone numbers, drop payment tokens, blank uploaded files. Do it as part of the restore, in one script, so there is no window in which a raw copy is sitting on a less-guarded machine.\n\nBe honest about what anonymisation does not achieve. A dataset with real behaviour in it can often be re-identified. If your data is sensitive, seeded fixtures are the safer answer even though they are less realistic.\n\n## Keep it cheap or it will be deleted\n\nAn always-on staging environment is a second bill for something used a few hours a week. Scale it to zero, use the smallest instance, use free tiers, and delete preview environments on merge. A staging environment that costs nothing when idle survives the first cost review; one that costs as much as production does not.\n\n## Feature flags are the third option\n\nSometimes the right answer is neither a branch nor a staging box: merge the code to main, ship it dark behind a flag, and turn it on for yourself, then for 1% of users. The code is in production, exercised by production traffic, with production data, and off.\n\nThis avoids the long-lived branch, which is its own tax: a branch alive for three weeks accumulates a merge conflict per day and gets tested against a world that has moved. Flags cost you cleanup (every flag is a branch in the code that must eventually be deleted) but a stale flag is a smaller problem than a stale branch."},"check":{"question":"A nightly job in your staging environment runs the \"re-engagement email\" task against a restored copy of the production database, using credentials that happen to be the live mail provider's. What is the first control that would have prevented the incident, rather than merely detected it?","options":[{"text":"Point every non-production environment at a mail catcher so no message can leave the machine at all","correct":true},{"text":"Anonymise the restored dump so the addresses are not real customers","correct":false,"tempting":"A genuinely necessary control, and it would have limited this one, but it is a second line of defence; the sending path itself is still live and the next task might not read addresses from the dump."},{"text":"Add an alert on outbound email volume so the spike is noticed quickly","correct":false,"tempting":"Detection, not prevention. The question asks what stops it, and forty thousand emails leave in well under a minute."},{"text":"Run the nightly job only on weekdays during working hours so somebody is watching","correct":false,"tempting":"Reduces the window in which nobody notices, but does nothing about whether the messages can be sent."}]},"prev":{"slug":"stopping-a-process-politely","title":"Ending a process without dropping a request","url":"https://addaly.com/learn/cloud-and-shipping/stopping-a-process-politely"},"next":{"slug":"migrations-without-downtime","title":"Changing the schema under a running site","url":"https://addaly.com/learn/cloud-and-shipping/migrations-without-downtime"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'The copy you are allowed to break', in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/staging-and-preview (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The copy you are allowed to break","courseTitle":"Shipping It","module":{"index":2,"slug":"making-a-deploy-boring","title":"Making a deploy boring"},"answer":null,"keyIdea":"A staging environment is worth having and will still lie to you, so make it cheap, make it disposable, and put a wall between it and anything that can reach a real person.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"why-a-second-environment-at-all","runs":[{"text":"Why a second environment at all"}]},{"t":"p","runs":[{"text":"So that the first time code meets a real deployment is not the first time real users meet it. That is the whole argument, and it is a good one, but be clear about what a second environment can and cannot tell you."}]},{"t":"p","runs":[{"text":"It can tell you:","b":true},{"text":" the build works, the migration applies, the config is complete, the pages render, the third-party integration is wired up."}]},{"t":"p","runs":[{"text":"It cannot tell you:","b":true},{"text":" how it behaves under real traffic, with real data volumes, with real cache states, with real users doing things nobody designed for. Staging has ten rows where production has ten million, so the query that takes 3ms there takes nine seconds live. Staging has one user, so you never see the lock contention."}]},{"t":"p","runs":[{"text":"Knowing which list you are in stops you from over-trusting a green staging run."}]},{"t":"h2","id":"preview-environments-beat-one-shared-staging","runs":[{"text":"Preview environments beat one shared staging"}]},{"t":"p","runs":[{"text":"The older pattern is a single long-lived staging box that everybody shares. It has a predictable failure: two people’s changes are on it at once, something breaks, and nobody knows whose."}]},{"t":"p","runs":[{"text":"The better pattern is "},{"text":"a fresh environment per branch or pull request","b":true},{"text":", created automatically and destroyed on merge. Every managed platform does this now, on free tiers. Each pull request gets a URL you can send to somebody."}]},{"t":"p","runs":[{"text":"The database is the awkward part, and there are three honest options:"}]},{"t":"ul","items":[[{"text":"Seed a small fixture dataset on creation. Fast, safe, unrealistic."}],[{"text":"Use a database that supports cheap branching: Neon’s branches are copy-on-write and take about a second."}],[{"text":"Restore an anonymised production dump nightly. Most realistic, most work, and the anonymisation must be real."}]]},{"t":"h2","id":"the-rule-that-matters-more-than-any-of-this","runs":[{"text":"The rule that matters more than any of this"}]},{"t":"p","runs":[{"text":"Nothing in a non-production environment may reach a real person.","b":true}]},{"t":"p","runs":[{"text":"The classic incident is not hypothetical and it happens somewhere every month: a test run in staging pointed at the production mail credentials, and forty thousand customers get an email that says "},{"text":"Hello {{first_name}}","code":true},{"text":". It goes out in under a minute. There is no recall."}]},{"t":"p","runs":[{"text":"The controls are boring and they work:"}]},{"t":"ul","items":[[{"text":"Non-production uses a mail catcher: "},{"text":"Mailpit","b":true},{"text":" or "},{"text":"MailHog","b":true},{"text":", both free, both a single binary. Every email is captured and viewable in a web UI, and nothing leaves the machine."}],[{"text":"If you must use a real provider, use its sandbox mode, or enforce an allowlist of recipient domains at the sending layer, refusing anything else."}],[{"text":"The same rule for SMS, push notifications and webhooks out. A staging webhook hitting a partner’s live endpoint is the same incident wearing different clothes."}]]},{"t":"p","runs":[{"text":"And the sharper version: "},{"text":"staging must never hold production credentials for anything that writes.","b":true},{"text":" Not the mail provider, not the payment processor, not the production database. If staging can write to production, staging is production with a friendlier name and worse discipline."}]},{"t":"h2","id":"anonymise-properly-or-do-not-copy-at-all","runs":[{"text":"Anonymise properly or do not copy at all"}]},{"t":"p","runs":[{"text":"If you restore a production dump, transform it: replace emails with "},{"text":"user+<id>@example.invalid","code":true},{"text":", blank phone numbers, drop payment tokens, blank uploaded files. Do it as part of the restore, in one script, so there is no window in which a raw copy is sitting on a less-guarded machine."}]},{"t":"p","runs":[{"text":"Be honest about what anonymisation does not achieve. A dataset with real behaviour in it can often be re-identified. If your data is sensitive, seeded fixtures are the safer answer even though they are less realistic."}]},{"t":"h2","id":"keep-it-cheap-or-it-will-be-deleted","runs":[{"text":"Keep it cheap or it will be deleted"}]},{"t":"p","runs":[{"text":"An always-on staging environment is a second bill for something used a few hours a week. Scale it to zero, use the smallest instance, use free tiers, and delete preview environments on merge. A staging environment that costs nothing when idle survives the first cost review; one that costs as much as production does not."}]},{"t":"h2","id":"feature-flags-are-the-third-option","runs":[{"text":"Feature flags are the third option"}]},{"t":"p","runs":[{"text":"Sometimes the right answer is neither a branch nor a staging box: merge the code to main, ship it dark behind a flag, and turn it on for yourself, then for 1% of users. The code is in production, exercised by production traffic, with production data, and off."}]},{"t":"p","runs":[{"text":"This avoids the long-lived branch, which is its own tax: a branch alive for three weeks accumulates a merge conflict per day and gets tested against a world that has moved. Flags cost you cleanup (every flag is a branch in the code that must eventually be deleted) but a stale flag is a smaller problem than a stale branch."}]}],"check":{"question":[{"text":"A nightly job in your staging environment runs the “re-engagement email” task against a restored copy of the production database, using credentials that happen to be the live mail provider’s. What is the first control that would have prevented the incident, rather than merely detected it?"}],"options":[{"text":[{"text":"Point every non-production environment at a mail catcher so no message can leave the machine at all"}],"correct":true,"tempting":null},{"text":[{"text":"Anonymise the restored dump so the addresses are not real customers"}],"correct":false,"tempting":[{"text":"A genuinely necessary control, and it would have limited this one, but it is a second line of defence; the sending path itself is still live and the next task might not read addresses from the dump."}]},{"text":[{"text":"Add an alert on outbound email volume so the spike is noticed quickly"}],"correct":false,"tempting":[{"text":"Detection, not prevention. The question asks what stops it, and forty thousand emails leave in well under a minute."}]},{"text":[{"text":"Run the nightly job only on weekdays during working hours so somebody is watching"}],"correct":false,"tempting":[{"text":"Reduces the window in which nobody notices, but does nothing about whether the messages can be sent."}]}],"order":[1,3,2,0],"why":null},"prev":{"slug":"stopping-a-process-politely","title":"Ending a process without dropping a request","minutes":8},"next":{"slug":"migrations-without-downtime","title":"Changing the schema under a running site","minutes":11}}}