Staging environment best practices
A production-mirror for testing changes
A staging environment that closely mirrors production is essential. Differences between staging and prod are where bugs hide.
What "mirrors prod" means
Same:
- Software versions.
- Configuration shape (different values for secrets, same keys).
- Hosting topology.
- TLS, CSP, rate limiting.
- Email provider (sandbox mode).
- Backup / monitoring setup.
Different:
- Domain names (
staging.your-app.comvsyour-app.com). - DB content (synthetic users, not real).
- Sometimes smaller resources (cheaper).
Provisioning
If using Terraform:
# envs/staging.tfvars
env = "staging"
domain = "staging.your-app.com"
host_type = "cax11" # smaller than prod's cax21terraform apply -var-file=envs/staging.tfvarsIf using a script:
ENV=staging ./scripts/provision.shSynthetic data
Don't copy prod data to staging, that's a data breach risk. Instead, seed synthetic users:
# scripts/seed-staging.sh
for i in $(seq 1 100); do
curl -X POST $KRATOS_ADMIN/admin/identities -d "{
\"schema_id\": \"default\",
\"traits\": { \"email\": \"staging-user-${i}@example.com\" },
\"credentials\": { \"password\": { \"config\": { \"password\": \"StagingPass123!\" } } },
\"state\": \"active\"
}"
doneOr use Faker:
import { faker } from "@faker-js/faker";
for (let i = 0; i < 1000; i++) {
const user = {
traits: {
email: faker.internet.email(),
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
},
};
// create
}Realistic patterns
Beyond user count, simulate behavior:
# Cron in staging: simulate logins
0 */1 * * * curl -X POST staging/login --data ...5-10 logins per hour. Audit log accumulates. Tests realistic load.
Differences to mind
Use sandbox / dev mode of your provider:
- Postmark: sandbox API key (returns success, doesn't actually send).
- SES: configuration set with no-op destination.
- Mailpit (self-hosted): all email visible in web UI.
So staging emails don't accidentally hit real customers.
Stripe / payment
Test mode (sk_test_...). Test card numbers (4242 4242 4242 4242).
OIDC providers
Some providers (Google, Apple) require redirect URIs to be registered. Register staging URIs:
https://staging-ciam.your-app.com/self-service/methods/oidc/callback/googleSame OAuth client OR separate. Prefer separate to avoid prod risk.
Public-facing rate limits
Staging is hit harder per identity (testing). Loosen rate limits if needed:
# staging override
rate_limits:
login: { events: 100, window: 1m } # vs 10 in prodWhen to refresh staging
After major changes:
- Schema migration → re-seed.
- New features → seed accounts using new features.
Don't keep cruft from past testing. Periodic teardown + reseed (monthly?).
Deploys to staging first
PR merges to main → deploy to staging → soak test (1h) → deploy to prodIf staging issues: revert PR. Prod never breaks.
Build into CI:
# .github/workflows/deploy.yml
on:
push:
branches: [main]
jobs:
staging-deploy:
runs-on: ubuntu-latest
steps:
- ssh staging "cd /opt/olympus && git pull && podman-compose up -d"
- run: ./scripts/smoke-test.sh staging
prod-deploy:
needs: staging-deploy
runs-on: ubuntu-latest
steps:
- run: sleep 3600 # soak
- ssh prod "cd /opt/olympus && git pull && podman-compose up -d"
- run: ./scripts/smoke-test.sh prodCost
Staging adds ~50-100% to your infra cost. Worth it.
To reduce:
- Smaller instance.
- Stop staging at night (cron).
- Single host containing both staging and prod (NOT recommended, staging issues can affect prod).
Branching: dev → staging → prod
If you want a third tier:
- Dev: local. Mock everything.
- Staging: shared. Real Olympus stack, sandbox external services.
- Prod: real.
For single-developer projects, dev + prod is fine. Multi-developer / multi-team: staging.
Access control
Staging may have less restricted access:
- Developers can SSH freely.
- Admin endpoints reachable from office IP.
But still:
- Don't expose to internet without auth.
- Use distinct credentials from prod (different DB password, different OAuth secrets).
- If staging is breached, prod must remain safe.
Sample data privacy
If you do copy prod DB to staging for realism:
- Hash / redact PII first.
UPDATE identities SET traits = jsonb_build_object(
'email', md5(traits->>'email') || '@redacted.local',
'first_name', 'Redacted',
'last_name', 'User'
);Better: synthetic-only. Avoids the question entirely.
"Production-like" testing
For testing things that need real third parties (real OAuth providers, real email, real SMS):
- Use a dedicated "staging account" at each provider.
- Costs add up (Twilio per SMS). Be disciplined about volume.