Testing in production is a joke that stops being funny the first time it costs you something. A staging environment is a second copy of your application you can break — and on a single server it costs a subdomain, a second database, and about an hour, most of which is spent on the things that must not be shared.
What this guide does not give you. Staging on the same machine as production is not isolation. It shares a kernel, a disk, a network interface and a memory limit — a runaway staging process can take production down with it, which is why step 6 exists. It also cannot test anything about the infrastructure itself: an operating system upgrade, a kernel change or a disk migration needs a separate machine. What it does test is your application, which is where most changes go wrong anyway.
1. Decide which kind of staging you want
Two useful answers, and mixing them produces something that serves neither purpose.
| A mirror | A scratchpad | |
|---|---|---|
| Data | A recent, anonymised copy of production | Whatever fixtures you like |
| Versions | Identical to production | Whatever you are testing |
| Answers | “Will this break on real data?” | “Does this feature work at all?” |
| Refreshed | Weekly, from a dump | Rarely, or reset on demand |
Most people want the mirror, because the bugs that reach production are usually about data volume and data shape rather than logic. Write down which one you are building; it decides everything below.
2. Separate everything that can be shared by accident
This is the actual work. Any resource you forget is one staging can reach into production through.
| Resource | What separating it looks like |
|---|---|
| Files | /srv/myapp-staging, never a symlink to production |
| Database | A separate database and a separate role |
| Uploads and media | Its own directory, or its own storage bucket |
| Cache and queues | A different Redis database number, or a second instance |
| Ports | 8081 rather than 8080, both on 127.0.0.1 |
| Scheduled jobs | Disabled entirely unless you are specifically testing one |
| Outbound email | Captured locally — see the warning below |
| Payment and third-party APIs | Sandbox credentials, never live keys |
| Webhooks | Pointed at staging, or switched off |
| Log files | Its own, so a staging error storm does not fill the disk that production writes to |
Email is how this goes badly wrong. Restore last night’s data into staging, run a job that sends notifications, and every one of your real customers gets it — from a test system, possibly several times. Before staging is allowed to run at all, point its mail configuration at something that cannot deliver: a local catcher such as MailHog or Mailpit, a provider’s sandbox mode, or an SMTP host of localhost with nothing listening. Do this first, not after the first accident.
sudo -u postgres createuser myapp_staging --pwprompt
sudo -u postgres createdb myapp_staging -O myapp_staging
mkdir -p /srv/myapp-staging/{data,uploads}
cp /srv/myapp/.env /srv/myapp-staging/.env # then edit every value
sudo chmod 600 /srv/myapp-staging/.envVerify: read the staging environment file line by line and confirm no value is still pointing at production — database name, upload path, mail host, API keys, callback URLs. This is a five-minute read that prevents the worst outcomes on this page.
3. Put it on its own hostname, behind a password
Staging goes on staging.example.com with the same TLS handling as production, and behind HTTP basic authentication so that half-finished work is not readable by anyone who guesses the subdomain.
caddy hash-password --plaintext 'a-long-random-string'
# Caddyfile
staging.example.com {
basic_auth {
team <the-hash-from-above>
}
header X-Robots-Tag "noindex, nofollow, noarchive"
reverse_proxy 127.0.0.1:8081
}The X-Robots-Tag header is the important line and belongs at the proxy rather than in the application. A robots.txt file asks crawlers not to fetch the page; this header tells them not to index it, which is the thing you actually want, and it applies to every response including ones the application generates without your involvement.
The basic-auth prompt does most of that work anyway — but the two together mean a staging site cannot end up outranking production for your own brand name, which is an embarrassing and surprisingly common outcome.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -sI https://staging.example.com # expect 401
curl -sI -u team:... https://staging.example.com | grep -i robotsVerify: an unauthenticated request returns 401, and an authenticated one carries X-Robots-Tag: noindex.
4. Refresh it from production, with the sharp edges filed off
A mirror is only useful if the data is recent, and only safe if the data has been de-fanged on the way in. Restore, then immediately anonymise — in one script, so the two can never be separated by someone in a hurry.
#!/usr/bin/env bash
set -euo pipefail
sudo -u postgres dropdb --if-exists myapp_staging
sudo -u postgres createdb myapp_staging -O myapp_staging
sudo -u postgres pg_restore -d myapp_staging /var/backups/myapp-latest.dump
sudo -u postgres psql -d myapp_staging <<'SQL'
UPDATE users SET
email = 'user' || id || '@example.com',
phone = NULL,
name = 'Test User ' || id;
TRUNCATE sessions, api_tokens, password_resets;
DELETE FROM payment_methods;
UPDATE settings SET value = 'https://staging.example.com' WHERE key = 'site_url';
SQLThree things that script does deliberately. It rewrites every email address to example.com, a domain reserved for exactly this and guaranteed not to deliver anywhere. It truncates sessions and tokens, so a copied session cannot be used against staging and a leaked staging dump contains no live credentials. And it fixes the site URL, because an application that thinks it is production will generate production links and redirect your testers there.
Treat the staging database as production data until that script has run — because until then, it is. That is also the argument for keeping the refresh on a timer rather than doing it by hand: an automated refresh always runs the anonymisation step.
Verify: after a refresh, SELECT email FROM users LIMIT 5; returns only example.com addresses, and SELECT count(*) FROM api_tokens; returns zero.
5. Turn off the things that act on their own
A copy of production is also a copy of production’s scheduled jobs. Nightly invoicing, subscription renewals, reminder emails, data exports to a partner — all of them will now run twice.
systemctl list-timers 'myapp*'
sudo systemctl disable --now myapp-staging-nightly.timer
crontab -l -u myapp_staging # check here tooDisable them all by default and enable individual ones only while you are testing that job specifically. If your application runs its own internal scheduler rather than using system timers, there is usually an environment variable to switch it off — find it before the first refresh, not after.
Verify: systemctl list-timers shows no staging timers active, and nothing appears in the staging logs overnight.
6. Stop staging from taking production with it
The whole point of staging is that you do reckless things in it. Cap what it can consume so a runaway import cannot starve the machine — systemd does this in three lines with no other tooling.
# /etc/systemd/system/myapp-staging.service.d/limits.conf
[Service]
MemoryMax=1G
CPUQuota=50%
IOSchedulingClass=idle
Nice=10sudo systemctl daemon-reload && sudo systemctl restart myapp-staging
systemd-cgtop # watch the two side by sideUnder Docker Compose the equivalent is mem_limit and cpus on the staging service. Either way, the effect is that staging gets killed rather than production getting slow.
Verify: run something deliberately memory-hungry in staging and confirm it is the staging process that dies, while production keeps answering.
7. Make promotion boring
Staging earns its keep only if deploying to it uses the same procedure as deploying to production, with a different environment file. If the two differ, staging has tested a deployment you are not going to perform.
./deploy.sh staging v1.9.0 # same script
./deploy.sh production v1.9.0 # same script, same versionDeploy the exact artefact you tested — the same image digest or the same commit — rather than rebuilding for production. A rebuild is a different thing that happens to have the same name.
Verify: the version staging reports and the version production reports after promotion are byte-for-byte the same artefact.
Before you call it done
- Staging cannot send email to a real address, and you have tested that
- Every value in the staging environment file has been read and changed
- The site returns 401 without credentials, and
noindexwith them - The refresh script anonymises in the same run that restores
- No staging timer or cron job is enabled
- Resource limits are in place and you have watched staging hit one
- The same deploy command works for both, with only the environment differing
Related
- A Reverse Proxy with Automatic TLS — where the subdomain and basic auth live
- Automated Backups — the dump staging is refreshed from
- systemd Beyond Services — drop-ins, slices and the resource limits used above
- Server Migration — the same separation problem, at a larger scale
