The point of staging
Staging exists to answer one question before customers do: does this build work on infrastructure shaped like ours? Local Docker setups miss TLS quirks, real DNS, proxy timeouts, file permissions and the cron job somebody added in 2023. A staging VM in the same London region, built from the same image as production, catches those.
Keep the differences deliberate and written down. Smaller plan, fake data, an auth wall, outbound email routed to a catch-all. Everything else should be identical, ideally built by the same scripts.
Option one: a single long-lived staging box
The simplest setup is one VM that tracks your main branch. CI deploys to it on every merge, QA tests there, and releases are promoted from the same artefact. Build it with the exact image production uses; if production runs Ubuntu 22.04, staging does too, even if a newer release is available.
Once it is configured and seeded, take a snapshot from the portal. Snapshots turn "staging is broken again" from an afternoon of archaeology into a restore and a coffee.
Option two: preview environments per branch
For teams that review UI changes, a URL per pull request is worth the setup. One VM can host many previews if each is a set of containers behind a router. Point a wildcard DNS record at the VPS:
*.preview.example.co.uk. 300 IN A 203.0.113.10
Then let Caddy issue certificates on demand and route by subdomain to the right container:
{
on_demand_tls {
ask http://127.0.0.1:9000/allowed
}
}
*.preview.example.co.uk {
tls { on_demand }
basicauth {
team $2a$14$REPLACE_WITH_CADDY_HASH
}
reverse_proxy {http.request.host.labels.4}-web:8080
}
The ask endpoint is a tiny service that returns 200 only for branches that exist, so nobody can make you request certificates for random names. Each preview runs as a Compose project named after the branch, attached to a shared Docker network the proxy can reach.
BRANCH=pr-418
docker compose -p "$BRANCH" -f compose.preview.yml up -d --build
# tear down when the PR closes
docker compose -p "$BRANCH" down -v
Data that behaves like real data
Empty databases hide bugs. Production copies create risk. The middle path is a seed script that generates realistic volumes: thousands of orders, long names, odd Unicode, accounts in every state your code handles. Keep it in the repository and run it on every fresh environment.
If you must start from production, anonymise in a pipeline before the data lands on staging. Replace emails, names, addresses and phone numbers, and truncate tables you do not need. Treat a raw production dump on a staging server as a data protection incident waiting to happen.
Keep it private
- Restrict ports 80 and 443 in the portal firewall to your office and VPN addresses, or put basic auth on every host.
- Send an
X-Robots-Tag: noindexheader as a second line of defence. - Route outbound mail to a capture tool such as Mailpit. Port 25 is blocked by default on new FastNode VMs, which is exactly what you want here.
- Use separate API keys, payment sandbox credentials and webhook secrets from production. Never share them.
Sizing and cost
| Setup | Plan |
|---|---|
| One staging copy of a small web app | Spark or Boost |
| Full stack staging with database and workers | Turbo |
| Five to ten concurrent branch previews | Nitro or Warp |
Staging rarely needs production's CPU, but previews multiply RAM use fast. Budget the memory of one full stack per open pull request, then add a cleanup job that removes previews for branches closed more than a day ago. On a monthly plan the bill stays flat no matter how many previews you spin up and tear down; our invoices are in US dollars rather than pounds for now.
Promote, don't rebuild
Build the artefact once in CI, deploy it to staging, test it, then deploy the same image digest to production. Rebuilding for production reintroduces the gap staging was meant to close. Pin digests, not tags, in your deploy step and the thing you tested is the thing you ship.