AI Code Tutorial · No. 01
Rotate database passwords on Railway, without restarting the database.
A brief you can hand to a coding agent. It changes the Postgres and Redis passwords behind a Flask app through a private tunnel, keeps both databases running, and tests every step before it moves on.
Why this, and why now.
On 21 October 2026 Railway disclosed that an unauthorised party may have reached the systems that store customer environment variables, and for some of them obtained the decrypted values. Its advice is plain: roll every variable stored on the platform. Read Railway’s security disclosure.
Most secrets are easy. You rotate an API key in the provider’s dashboard and paste the new one back. Database passwords are the awkward ones, because each one lives in two places at once: in the variable your app reads when it starts, and inside the database itself, which is what actually checks it.
Railway’s own guides use a Regenerate button that changes both in one step. It is the right call if you think a credential is being abused right now. It also restarts the database, drops every open session and leaves your apps unable to connect until you redeploy them.
This tutorial takes the other route. Your coding agent changes each half separately, and times the switch to the moment the new app container starts. The database never restarts. The window in which an app and its database disagree shrinks from minutes to seconds.
The idea
One password, two places.
The agent works from your machine. It reaches the databases through a private tunnel, and it changes the Railway variables through the CLI. Neither database is exposed to the internet at any point.
The variable.
What your app reads. Railway hands variables to a container once, when it starts. Changing a variable does nothing to a running app; only a new deployment picks it up. With --skip-deploys you can change it without restarting anything.
The database.
What checks the password. Postgres stores it in its own catalogue; Redis holds it in memory. Both accept a new password while running, and both keep already-open connections alive when it changes.
The whole method in one sentence: change the variable first without deploying, then flip the database at the exact moment the new app container boots.
The workflow
Six phases, each with a gate.
The agent does not move to the next phase until the current one passes its gate. Phases 0 to 2 change nothing your users can feel. Only phase 3 touches live traffic, and only after you say go.
Fig. 2 — The rotation, phase by phase
-
PHASE 0
Inspect
Read-only. Map who uses which password, and check what each database really holds.
Gate: Every consumer known
-
PHASE 1
Generate
New secrets in private files. Postgres hash computed locally.
Gate: Nothing printed
-
PHASE 2
Stage
New values set on Railway without deploying anything.
Gate: References resolve
-
PHASE 3
Redeploy & switch
Redeploy the app. Flip the password as its new container starts.
Gate: You said go
-
PHASE 4
Verify
Logs, connections, old password refused, real pages served.
Gate: All tests pass
-
PHASE 5
Clean up
Delete secrets, close tunnels, write the report.
Gate: Nothing left running
The switch window
Seconds, not minutes.
The order is what makes this safe. The agent switches the database only once the new container exists, so a build that hangs, fails or queues for twenty minutes changes nothing at all.
-
Stage
The variable now holds the new password. Nothing restarts; the running app still uses the old one.
-
Redeploy
A new container builds. The old one keeps serving. This can take minutes, and nothing has changed yet.
-
Switch · about 1–5 s
The new container starts. The agent changes the database password within a second or two.
-
Live
Traffic moves to the new container, which already has the new password.
One Redis, several apps.
When two apps share a Redis, switch it as soon as the first new container starts, not the last. Otherwise that first app boots with the new password while Redis still expects the old one, and runs on whatever its fallback does until the switch.
Before you start
Ten minutes of preparation.
Railway CLI, logged in
The agent uses it for tunnels, variables, redeploys and logs. Check with railway whoami.
An SSH key on your Railway account
The private tunnel rides on it. Remember that it opens tunnels to every project you own.
psql and redis-cli
On the machine the agent runs on. The psql major version does not need to match the server for these queries.
Builds that work
Check the Railway status page. During the incident builds were paused; a rotation that cannot deploy strands your app.
A recent backup
Nothing here touches your data, but this is production. Take one anyway.
Approve every command
Run the agent in a mode where you confirm each command. If your tool has an automatic mode, switch it off for this job.
Phase by phase
What the agent actually does.
You do not need to type any of this. It is here so you can follow along and know what a good run looks like.
Phase 0 · read-only
Inspect before touching.
- Map every consumer. Which services read which password, and whether as a reference like ${{Postgres.PGPASSWORD}} or as a pasted copy. Pasted copies do not follow a change; they get converted to references.
- Check for public access. A public TCP proxy means the database is reachable from the internet. If nothing outside the project needs it, switch it off first.
- Prove which password the database holds. Variables drift. The agent reads the stored SCRAM hash and checks each candidate variable against it locally, printing only true or false.
- Check logging. log_statement should be none, so nothing about the change lands in server logs.
- Know Redis. Uptime, persistence, users and connected clients. A Redis that has been up for a year with append-only persistence off is one you really do not want to restart.
- Read the app’s start-up code. Does it retry the database on boot? Does its “Redis unavailable” fallback actually work with the library versions installed today?
# a private tunnel per database (discard output: it can echo secrets) railway connect "Postgres" --tunnel-only -P 55432 >/dev/null 2>&1 & railway connect "Redis" --tunnel-only -P 56379 >/dev/null 2>&1 & # which variables does the stored hash match? (true/false only) psql "host=127.0.0.1 port=55432 user=postgres" -XAt \ -c "select rolpassword from pg_authid where rolname='postgres'" # logging, roles, who is connected psql ... -c "show log_statement" -c "show password_encryption" psql ... -c "select usename, client_addr, count(*) from pg_stat_activity group by 1,2" # the tunnel lands on 127.0.0.1: a wrong password still connects PGPASSWORD=wrong psql "host=127.0.0.1 port=55432 user=postgres" -c "select 1" redis-cli -h 127.0.0.1 -p 56379 INFO server | grep uptime redis-cli -h 127.0.0.1 -p 56379 CLIENT LIST
Gate: the agent reports every consumer, every mismatch and a plan per app, then waits.
Phase 1
Generate, never print.
- 40 random letters and digits per database, from a cryptographic generator. Long, and nothing that needs escaping in a URL.
- Private files, not the screen. A scratch folder outside the repository, readable by you only. Secrets move through files and standard input.
- Keep the old Redis password. You need it to authenticate the change. Save it before staging anything.
- Hash the Postgres password locally. The agent computes the SCRAM-SHA-256 verifier on your machine and sends only that. The plain password never reaches the server.
# the verifier Postgres stores, computed locally import base64, hashlib, hmac, os def scram(pw, it=4096): salt = os.urandom(16) sp = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, it) ck = hmac.new(sp, b"Client Key", "sha256").digest() sk = hmac.new(sp, b"Server Key", "sha256").digest() b = lambda x: base64.b64encode(x).decode() return (f"SCRAM-SHA-256${it}:{b(salt)}" f"${b(hashlib.sha256(ck).digest())}:{b(sk)}") # later: ALTER ROLE postgres PASSWORD '<verifier>';
Gate: each verifier checks out against its password, and nothing secret appeared in the output.
Phase 2
Stage without deploying.
- --skip-deploys is the whole trick. Without it, Railway redeploys the database and you are back to a restart.
- Fix pasted copies now. An app holding a literal password gets a reference instead, so it can never drift again.
- Check by comparison, not by looking. The agent confirms each app’s resolved variable equals the new secret, and that no deployment started.
# new value on the database service, no redeploy railway variable set POSTGRES_PASSWORD --stdin --skip-deploys \ -s "Postgres" < scratch/pg_new railway variable set REDIS_PASSWORD --stdin --skip-deploys \ -s "Redis" < scratch/redis_new # a pasted copy becomes a reference printf '%s' '${{Postgres.PGPASSWORD}}' | \ railway variable set PGPASSWORD --stdin --skip-deploys -s "web"
Gate: every reference resolves to the new value and nothing has redeployed. Then the agent stops and asks you.
Phase 3 · live traffic
Redeploy, then switch on cue.
- Redeploy the app and poll its deployment status every three seconds.
- On DEPLOYING, switch. ALTER ROLE with the verifier for Postgres; CONFIG SET requirepass for Redis, authenticated with the saved old password. Open connections stay logged in.
- Build failed or stuck? Do not switch. The old container keeps working on the old password. Nothing is broken.
- Keep watching after success. A container can report success and crash twenty seconds later. The agent watches for a minute.
railway redeploy -s "web" -y # poll until the new deployment leaves BUILDING railway deployment list -s "web" --json # every 3 s # the moment it reads DEPLOYING: psql "host=127.0.0.1 port=55432 user=postgres" \ -v ON_ERROR_STOP=1 -f scratch/alter.sql # alter.sql: ALTER ROLE postgres PASSWORD 'SCRAM-SHA-256$...'; printf 'CONFIG SET requirepass %s\n' "$(cat scratch/redis_new)" | \ REDISCLI_AUTH="$(cat scratch/redis_old)" \ redis-cli -h 127.0.0.1 -p 56379 --no-auth-warning
Gate: the stored hash equals the new verifier, Redis answers the new password, and the deployment is still healthy a minute later.
Phase 4
Verify like a sceptic.
A homepage returning 200 proves nothing if a CDN cached it. The agent requests a page that needs the database, with a cache-busting query string, reads the new deployment’s logs for authentication errors, and confirms the old password is refused. The full list is in the table below.
Phase 5
Leave nothing behind.
Delete the scratch secrets and scripts. Close every tunnel, and check the ports: stopping the CLI can leave the underlying SSH process listening. Finish with a short written report of what changed, every check and its result, and any downtime with timestamps.
The checklist
Every test the agent runs.
Twelve checks. None of them prints a secret; every comparison is true or false.
| When | Test | How | Pass |
|---|---|---|---|
| Before | Stored password matches the variables | Check the SCRAM hash in pg_authid against each candidate variable, locally | Each app’s copy matches |
| Before | Statement logging is off | show log_statement | none |
| Before | Tunnel uses local trust | Connect through the tunnel with a deliberately wrong password | Connects |
| Before | Builds are working | Status page, and a recent successful deployment | Green |
| Stage | References resolve to the new secret | Compare each app’s resolved variable with the new secret | All true, no deploy started |
| Switch | New hash is stored | rolpassword equals the new verifier | true |
| Switch | Redis takes the new password | PING with the new password | PONG |
| Switch | Redis refuses the old one | PING with the old password | WRONGPASS |
| After | Deployment stays healthy | Deployment status, one minute after success | Still SUCCESS |
| After | No authentication errors | Logs: password authentication failed, WRONGPASS, NOAUTH, Traceback | None after the switch |
| After | The app really uses the database | Uncached request to a database-backed page | 200 |
| After | Nothing left running | Scratch folder and tunnel ports | Empty and closed |
Failure modes
What can go wrong, and the fix.
The build hangs.
The agent waits and switches nothing. The old container keeps serving on the old password. Retry the build later.
The new container crashes after the switch.
Read its logs. Both halves of the password now match, so a fresh redeploy usually comes up clean.
A fallback path is broken.
Code that only runs when Redis is down has often never run at all. Read it in phase 0, and pin the library it depends on.
A password was pasted, not referenced.
That app keeps the old value and fails after the switch. Phase 0 finds these; phase 2 converts them.
A tunnel outlives the session.
Stopping the CLI can leave its SSH process holding the port. Check with lsof and close it.
A secret gets printed.
Treat it as exposed and rotate that one again. The prompt forbids printing, but tools can echo connection strings.
The prompt
Hand this to your coding agent.
Fill in the parts in square brackets. The agent inspects first, reports, and waits for your go-ahead before it touches live traffic.
You are going to rotate the database passwords behind a web app hosted on Railway.
Project: [Railway project name], environment: [production]
Databases: [Postgres service name(s)], [Redis service name]
Apps that use them: [app service name(s)]
Goal: a new password for every database, with no database restart and no more than a few seconds in which an app and its database disagree.
HARD RULES
- Never print, log or paste a secret. Keep secrets in files with mode 600 in a scratch folder outside the repository. Pass them through files or standard input. Compare values with equality or hash checks only.
- Never restart, redeploy or "Regenerate" a database service. Use --skip-deploys on every variable change.
- Work read-only until I approve. Stop and wait for me after Phase 0 and after Phase 2, and whenever a check fails.
- If a command is denied, do not look for a workaround. Tell me.
PHASE 0 - INSPECT (read-only)
1. Confirm the Railway CLI is logged in and linked to the right project and environment. Confirm builds are working: a recent successful deployment, and the Railway status page.
2. For each database, list every service that uses its credentials and whether each one uses a reference (${{Service.VAR}}) or a pasted literal. Report every literal.
3. Report any database with a public TCP proxy. Do not change it.
4. Open one tunnel per database: railway connect "<service>" --tunnel-only -P <port>. Discard its output; it can contain secrets.
5. Postgres: read the stored SCRAM verifier from pg_authid and check locally which variables it matches (POSTGRES_PASSWORD, PGPASSWORD and each app's copy). Report the server version, log_statement, password_encryption, login roles and current connections.
6. Connect through the tunnel with a deliberately wrong password. If it does not connect (no local trust), stop: we must save the old password before staging.
7. Redis: PING with the current password. Report the version, uptime, persistence settings, ACL users (hashes redacted) and connected clients.
8. Read the app's start-up code. Does it retry the database on boot? What does it do if Redis rejects it, and does that fallback work with the library versions installed in production?
Then report your findings and a plan per app, and wait.
PHASE 1 - GENERATE
9. Generate a 40-character alphanumeric password per database with a cryptographically secure generator, into mode-600 files.
10. Save the current Redis password to a file before changing anything.
11. For Postgres, compute the SCRAM-SHA-256 verifier locally (4096 iterations, random 16-byte salt) and check it against the new password.
PHASE 2 - STAGE (no restarts)
12. Set the new POSTGRES_PASSWORD and REDIS_PASSWORD on the database services with: railway variable set <VAR> --stdin --skip-deploys -s "<service>"
13. Replace every pasted literal in an app with a reference to the database service's variable.
14. Confirm each app's resolved variable now equals the new secret, and that no deployment started.
Then show me the exact plan for Phase 3, and wait.
PHASE 3 - REDEPLOY AND SWITCH (only after my go-ahead)
15. Redeploy one app, or one batch. Poll railway deployment list --json every 3 seconds.
16. The moment its new deployment reaches DEPLOYING, run ALTER ROLE <user> PASSWORD '<verifier>' through the tunnel. Then confirm the stored hash equals the new verifier.
17. For a Redis shared by several apps, run CONFIG SET requirepass (authenticated with the saved old password, value sent over standard input) as soon as the FIRST consumer's new container starts, not the last.
18. If a deployment fails or hangs, do not switch that database. Report and wait.
19. Watch each new deployment for 60 seconds after it reports SUCCESS. If it crashes, read its logs, report, and redeploy once the cause is understood.
PHASE 4 - VERIFY
20. Each new deployment still reports SUCCESS after 60 seconds.
21. The new deployments' logs show no "password authentication failed", WRONGPASS, NOAUTH or tracebacks after the switch.
22. Redis: the old password gets WRONGPASS; the new one gets PONG.
23. pg_stat_activity and CLIENT LIST show connections from the new containers.
24. A database-backed page returns 200 with a cache-busting query string, so no CDN cache answers for it.
PHASE 5 - CLEAN UP
25. Delete the scratch secrets and scripts.
26. Close every tunnel, and check with lsof that no SSH process still listens on the tunnel ports.
27. Give me a short report: what changed, each check and its result, any downtime with timestamps, and anything left for me (local .env files, external tools that used the old passwords).
Works with any agent that can run shell commands: Claude Code, Codex, Cursor and the like. Run it with command approval switched on.
Questions
The short answers.
Does changing a Postgres password on Railway need a database restart?
No. PostgreSQL keeps role passwords in its own catalogue, so ALTER ROLE takes effect immediately and the server keeps running. The POSTGRES_PASSWORD variable is only read when the data directory is first created. Railway’s Regenerate button restarts the database to apply a new password; the method in this tutorial does not.
Do open connections drop when the password changes?
No. In both PostgreSQL and Redis, sessions that are already authenticated stay authenticated. Only new connections need the new password. That is why the switch can be timed to the moment a new app container starts.
Why not just press Regenerate in the Railway dashboard?
It is simpler and it is Railway’s documented route, so use it if you believe a credential is being abused right now. The cost is a database restart, dropped sessions and an outage until every dependent service has been redeployed. This tutorial trades that for a few more steps and an agent doing them carefully.
Is it safe to let an AI agent touch production database credentials?
It can be, with rules. The agent never prints a secret, works read-only until you approve, stops at fixed gates, and runs in a permission mode where you approve each command. Treat it like a careful junior engineer with a checklist, not an autopilot.
What happens if the app build fails halfway through?
Nothing breaks. The database password is only switched once the new container is starting, so a failed or stuck build leaves the old container running on the old password. You retry the build, then switch.
Does this only work for Flask?
No. Any app that reads its database and Redis credentials from environment variables at start-up behaves the same way: Django, FastAPI, Rails, Node, Celery workers. The example here is a Flask app served by gunicorn because that is a common Railway setup.