AI Code Tutorial · No. 02
Your app does not need the keys to the whole database.
Most apps connect as root or postgres because that is the credential the platform hands out. This tutorial explains what that costs you when a secret leaks, and gives a coding agent a brief to move your app to its own least-privilege MySQL or Postgres user, on Railway or anywhere else, without downtime.
Why this matters.
Create a MySQL or Postgres database on most platforms and you get one account: the superuser. Its password goes into a connection string, the connection string goes into your app’s variables, and from then on your web server talks to the database with the same powers as the person who administers it.
That is fine until the credential leaks, and secrets in environment variables leak in many ordinary ways: a committed .env file, an error tracker that captures the environment, a debug page left on, a CI log, a compromised dependency, or the platform itself. On 21 October 2026 Railway disclosed that some customers’ decrypted variables may have been obtained by an unauthorised party. SQL injection is the other route in: every injected query runs with whatever rights your app has.
The fix is old and simple: least privilege. Give the app a user that can read and write its own rows and nothing else. Keep the superuser for the rare jobs that need it, and keep it out of the running app. A leak of the app’s credential is then a data incident you can contain, not a full takeover of the database server.
The blast radius
What a leaked credential can do.
The same leak, two outcomes. The left column is what someone holding your app’s credential can do today if the app connects as the superuser. The right column is what they can do once it connects as a dedicated user.
| An attacker can | How | App as superuser | App as its own user |
|---|---|---|---|
| Read and change the app’s rows | SELECT, INSERT, UPDATE, DELETE | Yes | Yes |
| Drop, empty or alter tables | DROP, TRUNCATE, ALTER | Yes | No |
| Read other databases on the same server | Every schema, not just yours | Yes | No |
| Create a new user or grant rights | A backdoor that survives your next rotation | Yes | No |
| Read other accounts’ password hashes | mysql.user, pg_authid | Yes | No |
| Read or write files on the database host | MySQL FILE privilege, Postgres server file roles | Yes | No |
| Run shell commands on the database host | Postgres COPY … FROM PROGRAM | Yes | No |
| Change server settings or end other sessions | SET GLOBAL, KILL, pg_terminate_backend | Yes | No |
| Lock you out | Change the superuser’s own password | Yes | No |
The worst row is persistence.
A superuser can create a second account in seconds. You rotate the leaked password, feel safe, and the attacker logs in with the account they made last week. With a dedicated app user that door does not exist: CREATE USER is refused.
On Postgres, superuser means shell.
COPY … FROM PROGRAM runs any command on the database host as the operating-system user that runs Postgres. Only superusers and members of pg_execute_server_program can use it. Keep your app out of both.
The idea
One job, one user.
Every component gets a credential that fits its job. The running app reads and writes rows. Schema changes happen with a stronger credential that never sits in the app’s environment. The superuser is kept for administration.
The app user.
Lives in the app’s environment, because the app needs it on every request. It can SELECT, INSERT, UPDATE and DELETE in one database. It cannot change the schema, manage users or touch the server.
The strong credential.
Used for migrations and administration, a few times a month at most. It stays on the database service, or in your password manager, and you reach the database through a private tunnel when you need it. If it is not in the app’s environment, an app leak cannot reveal it.
How far to go
Three levels of separation.
The deciding question is where your migrations run. Pick the level your deploy process allows today; each one is a real improvement on the superuser.
Owner of its own database
The app user may also create and alter tables, but only in its own database. Use this if migrations run when the app boots and you cannot change that yet.
Removes: other databases, user management, files, shell, server settings.
Rows only, migrations elsewhere
The app user reads and writes rows, nothing more. Migrations run by hand through a tunnel or from a CI job, with a credential the app never sees. The default this tutorial aims for.
Also removes: dropping or altering tables through the app credential.
One user per component
Each worker, sidecar or reporting tool gets its own user with only the tables, or even the columns, it touches. A reporting tool gets SELECT only.
Also gives you: per-component identity in the connection list, and a small blast radius per leak.
Migrations at boot need one honest decision.
Many frameworks migrate in the start or pre-deploy command, with the app’s own variables. Giving that step a second, stronger variable puts the strong credential back in the app’s environment, where a leak reveals both. If you cannot move migrations out yet, choose Level 1 now and plan the move to Level 2. Do not leave the superuser in place while you decide.
The workflow
Six phases, each with a gate.
The agent does not move on until the current phase passes its gate. Nothing your users can feel happens before phase 4, and phase 4 waits for your go-ahead.
Fig. 3 — Moving the app to its own user, phase by phase
-
PHASE 0
Inspect
Read-only. Who connects as whom, hardcoded users in the code, objects the superuser owns, how migrations run.
Gate: You approve a plan
-
PHASE 1
Design
Choose the level and the exact grants. Decide where migrations will run.
Gate: Grants written down
-
PHASE 2
Create
Create the user next to the superuser. Test what it can do, and what it must not.
Gate: Allowed in, denied out
-
PHASE 3
Fix the code
Remove hardcoded user names. Deploy while still on the superuser, so nothing changes yet.
Gate: Same behaviour, live
-
PHASE 4
Stage & switch
Store the new password, point the app at it without deploying, then redeploy once.
Gate: You said go
-
PHASE 5
Verify & clean up
Connections, logs, writes, real pages. Delete the scratch secrets, close the tunnel.
Gate: All tests pass
Before you start
Ten minutes of preparation.
A private way in
On Railway, the CLI with railway connect --tunnel-only. Elsewhere, an SSH tunnel or a bastion. Never open the database to the internet for this job.
The database client
mysql or psql on the machine the agent runs on. Admin SQL goes through these, from a file, not through your app’s driver.
A healthcheck that queries the database
If the deploy healthcheck runs a real query, a wrong credential fails the deploy and the old container keeps serving. Add one first if you have none.
A recent backup
Nothing here touches your data, but this is production. Know where the last backup is and that it completed.
A clean working tree
Phase 3 commits a small code fix. Start from a branch that matches what is deployed, so nothing else ships with it.
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, with MySQL and Postgres side by side. The examples call the app user app_rw and the database railway, the default name on Railway; use your own.
Phase 0 · read-only
Inspect before touching.
- Who connects as whom. List the open connections by user and address, and map every service that holds the database credential. You may find more consumers than you expected.
- Hardcoded user names. Search the code for 'root', 'postgres' and every place that opens a connection. An old module with its own settings will keep using the superuser, or break, after the switch.
- What the app does at runtime. Does it create or alter tables while running? Does it call admin functions, lock tables, read server files? Search the code, not just the migrations folder.
- How migrations run. By hand, in CI, in a pre-deploy command, or at boot. This decides your level.
- What the superuser owns. On MySQL, triggers, views, routines and events with the superuser as definer. On Postgres, which role owns the tables and sequences. These decide why the superuser must stay.
- Logging and TLS. Statement logs that would record a password, and whether clients connect over TLS.
MySQL
-- who is connected, as whom SELECT user, host, db, command, time FROM information_schema.processlist; -- objects that run as the superuser SELECT 'trigger', trigger_name, definer FROM information_schema.triggers UNION ALL SELECT 'view', table_name, definer FROM information_schema.views UNION ALL SELECT 'routine', routine_name, definer FROM information_schema.routines UNION ALL SELECT 'event', event_name, definer FROM information_schema.events; -- will a CREATE USER land in a log? SELECT @@version, @@general_log, @@log_bin;
Postgres
-- who is connected, as whom SELECT usename, application_name, client_addr, backend_start, state FROM pg_stat_activity WHERE backend_type = 'client backend'; -- who owns the tables and sequences SELECT n.nspname, c.relkind, r.rolname, count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_roles r ON r.oid = c.relowner WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND c.relkind IN ('r','S','v','m') GROUP BY 1,2,3; -- will a password land in a log? SHOW server_version; SHOW log_statement;
Gate: the agent reports every consumer, every hardcoded user, every runtime schema change and how migrations run, then proposes a level and waits.
Phase 1
Design the grants.
- Level 2 by default. Rows only: SELECT, INSERT, UPDATE, DELETE on the app’s database. Nothing on the server, nothing on other databases.
- Postgres needs two extras. USAGE on the schema, and USAGE, SELECT on sequences so serial ids keep working on insert.
- Think about tomorrow’s tables. On Postgres, grants cover existing tables only. Default privileges extend them to tables the migration role creates later. On MySQL, a grant on railway.* already covers future tables.
- Optional limits. A connection cap a little above your pool size, and on Postgres a per-role statement timeout.
Grants by level
- Level 1. MySQL: add CREATE, ALTER, INDEX, DROP, REFERENCES on railway.*. Postgres: make app_rw own the schema and tables.
- Level 2. The grants in phase 2 below, exactly.
- Level 3. Per table or per column. MySQL: GRANT UPDATE (balance) ON railway.accounts. Postgres: GRANT UPDATE (balance) ON accounts.
- Reporting tool. SELECT only, ideally on a read replica.
Gate: the exact grant statements are written down, and you know where migrations will run.
Phase 2
Create the user, then try to misuse it.
- A long random password, never printed. 40 letters and digits from a cryptographic generator, written to a private file. Letters and digits only, so it never needs escaping inside a URL.
- SQL from a file. The statement holding the password is written to a mode-600 file and fed to the client. Not on the command line, where it shows up in the process list and shell history.
- Test both directions. Logged in as the new user, the agent reads a real table. Then it tries what must fail: reading account hashes, creating a table, creating a user. Each refusal is a pass.
MySQL
-- create.sql, mode 600, password from a file CREATE USER 'app_rw'@'%' IDENTIFIED BY '<secret>'; GRANT SELECT, INSERT, UPDATE, DELETE ON `railway`.* TO 'app_rw'@'%'; -- optional: cap connections above pool size ALTER USER 'app_rw'@'%' WITH MAX_USER_CONNECTIONS 60; -- run it, then delete the file mysql --defaults-file=scratch/root.cnf < scratch/create.sql -- as app_rw: must work SELECT CURRENT_USER(); SHOW GRANTS; SELECT COUNT(*) FROM railway.<table>; -- as app_rw: must fail, one connection each SELECT COUNT(*) FROM mysql.user; -- 1142 CREATE TABLE railway.zz_probe (i int); -- 1142 CREATE USER probe IDENTIFIED BY 'x'; -- 1227
Postgres
-- create.sql, run as the table owner \set pw `cat scratch/app_pw` CREATE ROLE app_rw LOGIN PASSWORD :'pw' CONNECTION LIMIT 60; GRANT CONNECT ON DATABASE railway TO app_rw; GRANT USAGE ON SCHEMA public TO app_rw; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_rw; -- tables the migration role creates later ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app_rw; -- as app_rw: must fail SELECT rolpassword FROM pg_authid; -- denied CREATE TABLE zz_probe (i int); -- denied CREATE ROLE probe; -- denied COPY (SELECT 1) TO PROGRAM 'true'; -- denied
Gate: the new user reads a real table, every forbidden action is refused, and no secret appeared in the output.
Phase 3
Fix the code first, while nothing changes.
- Read the user from the environment. Every connection, including the forgotten module with its own settings, takes the user from the same variable, defaulting to today’s value.
- Deploy it before the switch. The variable still says superuser, so behaviour is identical. If the deploy goes wrong, you learn it now, with no credential change mixed in.
- Commit only that change. Stage the files the fix touches, nothing else from the working tree.
# before: a module with its own settings DB = { 'user': 'root', 'password': os.environ.get('MYSQLPASSWORD'), ... } # after: same value today, app_rw tomorrow DB = { 'user': os.environ.get('MYSQLUSER', 'root'), 'password': os.environ.get('MYSQLPASSWORD'), ... }
Gate: the fix is live, the app still connects as the superuser, and every page that uses the fixed module still works.
Phase 4 · live traffic
Stage, then switch with one redeploy.
- Store the password once. On the database service, as its own variable. The app references it, so there is a single place to rotate it later.
- Point the app at the new user without deploying. Change the user and password variables, staged so that nothing restarts. Check the resolved values by comparison, never by printing.
- Redeploy the same build. One redeploy of the existing deployment. The healthcheck queries the database, so a wrong credential fails the deploy and the old container keeps serving.
- Remove the superuser from the app. Afterwards, no app variable may reference the superuser password, including a leftover full connection string.
# the password lives on the database service railway variable set APP_DB_PASSWORD --stdin \ --skip-deploys -s "MySQL" < scratch/app_pw # the app points at it (MySQL template names) printf '%s' 'app_rw' | railway variable set \ MYSQLUSER --stdin --skip-deploys -s "web" printf '%s' '${{MySQL.APP_DB_PASSWORD}}' | \ railway variable set MYSQLPASSWORD --stdin \ --skip-deploys -s "web" # or, for an app that reads DATABASE_URL (Postgres) # postgresql://app_rw:${{Postgres.APP_DB_PASSWORD}} # @${{Postgres.PGHOST}}:${{Postgres.PGPORT}} # /${{Postgres.PGDATABASE}} # one redeploy of the build that is live railway redeploy -s "web" -y
Gate: the new deployment passed its healthcheck, and the database shows the app connected as the new user only.
Phase 5
Verify like a sceptic.
The connection list must show the app as the new user, from the new container, and no app connection as the superuser. The logs must show no permission errors. Pages that read the database must load, with a cache-busting query string so a CDN cannot answer for them. And writes must land: new rows created after the switch prove that INSERT works.
Then
Leave nothing behind.
Delete the scratch passwords, client config files and SQL files. Close the tunnel and check the port: stopping the CLI can leave the SSH process listening. Write down, somewhere your team will find it, that migrations now need the strong credential and why the superuser must stay.
Railway specifics
What is different on Railway.
Railway’s database templates create exactly one account and wire it into reference variables. That is convenient, and it is why almost every Railway app connects as the superuser.
What the templates give you
MySQL: root, with MYSQLUSER, MYSQLPASSWORD, MYSQL_URL and friends. Postgres: postgres, with PGUSER, PGPASSWORD and DATABASE_URL. An app that references these runs as the superuser.
References, not copies
Store the new password on the database service and reference it from the app with ${{Service.VAR}}. A dotted line on the canvas means a reference; no line usually means a pasted copy that will drift.
The CLI shows resolved values
railway variables prints what the app receives, not whether it is a reference. The raw editor in the dashboard shows the template; check it before and after.
Stage with --skip-deploys
Every variable change, on the app and on the database, uses --skip-deploys. Without it, a change to the database service restarts the database.
Keep the superuser
The database tab in the dashboard and railway connect use the template credentials. Leave root or postgres in place with its current password.
The Postgres tunnel trusts you
Through railway connect, Postgres accepts local connections without checking the password, so a successful login there proves nothing. The deploy healthcheck is the real password test. MySQL checks passwords over the tunnel.
On any other platform
The same rule, everywhere.
The SQL above works on a VPS, in Docker, and on managed databases. A few things are worth knowing whatever you run on.
Managed admin users are still admins
The account a managed service gives you may not be a true superuser, but it can usually create users and grant rights. That is reason enough to keep it out of the app.
Narrow where connections come from
MySQL: the host part of the user. Postgres: pg_hba.conf rules by address and database. Add these on top of a private network, not instead of one.
Require TLS for the app user
MySQL: REQUIRE SSL on the user. Postgres: hostssl lines in pg_hba.conf. Check that every client already uses TLS before you enforce it.
Limits per user
A connection cap stops one leaked credential from exhausting the server. On Postgres, ALTER ROLE app_rw SET statement_timeout = '30s' stops runaway queries for that role only.
Independent rotation
With separate users, rotating the superuser never touches the app, and rotating the app’s password is one user and one variable. A secrets manager can rotate each on its own schedule.
Identity in the connection list
Separate users, plus an application_name on Postgres, tell you which service runs which query. During an incident that answer is worth a lot.
Going further on Postgres.
A clean pattern for Level 2 is an owner role that cannot log in, app_owner, which owns the schema and every table. Migrations run as a login role that is a member of it and switches with SET ROLE app_owner, so new tables always have the same owner and default privileges always apply. Row-level security can then restrict which rows each role sees, which is beyond this tutorial.
The checklist
Every test the agent runs.
Fifteen checks. None of them prints a secret; every comparison is true or false.
| When | Test | How | Pass |
|---|---|---|---|
| Before | Every consumer is known | Connection list by user and address, matched against services that hold the credential | No unknown client |
| Before | No hidden superuser in the code | Search for hardcoded user names and every connection site | All read the variable |
| Before | No schema changes at runtime | Search the app code, outside migrations, for CREATE, ALTER, DROP, TRUNCATE | None, or Level 1 |
| Before | Password will not be logged | MySQL general log and binary log; Postgres log_statement | Off, or none |
| Create | The new user reads real data | Log in as the new user and count rows in an app table | Rows returned |
| Create | Grants are exactly as designed | SHOW GRANTS, or the Postgres privilege functions | Nothing extra |
| Create | Forbidden actions are refused | Read account hashes, create a table, create a user, run a program | All denied |
| Code | The fix changes nothing yet | Deploy it; check the connection list and the pages that use the fixed module | Still superuser, still working |
| Stage | Variables resolve to the new user | Compare the resolved user and password with the expected values | True, no deploy started |
| Switch | Deployment passes its healthcheck | Deployment status, and the healthcheck runs a real query | SUCCESS |
| After | The app is the new user | Connection list: the app’s connections and their user | New user only |
| After | No permission errors | Logs: access denied, permission denied, command denied | None after the switch |
| After | Writes land | Rows created after the switch in tables the app writes to | New rows present |
| After | No app variable holds the superuser | Raw variables of every app service | No reference to it |
| After | Nothing left behind | Scratch folder and tunnel port | Empty and closed |
Failure modes
What can go wrong, and the fix.
A forgotten module still says root.
Only the pages it serves break after the switch, often the ones nobody tests. Phase 0 searches for every connection site; phase 3 fixes them before the switch.
Migrations at boot fail.
“CREATE command denied” during start-up. The healthcheck fails and the old container keeps serving. Move migrations out, or use Level 1.
Tomorrow’s table is invisible.
Postgres default privileges only apply to tables created by the role named in them. If migrations run as another role, new tables come without grants. Name the right role, or use one owner role.
Inserts fail on a sequence.
“permission denied for sequence” on Postgres tables with serial ids. Grant USAGE, SELECT on the sequences, now and by default.
Someone deletes the superuser.
MySQL triggers and views with it as definer fail with “the user specified as a definer does not exist”, and platform tools lose access. Keep it.
A plain connection gets 1045.
MySQL’s default caching_sha2_password needs TLS, or RSA key exchange, for the first login after the user is created or the server restarts. Keep TLS on in every client.
The user ends up as app_rw@%%.
Some drivers escape % in parameterised queries and never unescape it, creating host %%. Run admin SQL through the database client, from a file, not through the app’s driver.
A permission test left rows behind.
On MySQL, any DDL statement, even a refused one, commits the open transaction first. Never mix a rolled-back write test with DDL probes; run each probe on its own connection.
A Postgres login test proves nothing.
Through a local tunnel that uses trust authentication, any password is accepted. Test the password with the deploy healthcheck, or from inside the app container.
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 move a web app off the database superuser and onto a dedicated, least-privilege database user.
Platform: [Railway, or describe yours]. Project: [name], environment: [production]
Database: [MySQL or Postgres] service [service name], database [database name]
Apps that connect to it: [app service name(s)]
New user name: [app_rw]
Goal: every app connects as its own user with only the rights it needs, the superuser stays for administration and migrations, and users notice nothing.
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 checks only.
- Never drop, rename, lock or change the password of the superuser (root or postgres).
- Run admin SQL with the database client (mysql or psql) from a file. Never through the app's driver, and never with a password on the command line.
- Change platform variables without triggering a deploy (on Railway: --skip-deploys on every change, on every service). Never restart the database.
- Never open the database to the public internet. Use a private tunnel (on Railway: railway connect --tunnel-only).
- Commit only the files you change for this job. Ask me before pushing.
- Work read-only until I approve. Stop and wait for me after Phase 0, after Phase 2 and after Phase 3, 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 you are linked to the right project and environment, and that the latest app deployment is healthy.
2. List every service that holds the database credential, and for each variable whether it is a reference or a pasted literal (on Railway the CLI shows resolved values only; read the raw values).
3. Open a tunnel. List current connections by user and address (MySQL information_schema.processlist; Postgres pg_stat_activity) and match each address to a service.
4. Search the code for hardcoded user names ('root', 'postgres') and for every place that opens a database connection. Report each one and whether it is live code.
5. Search the app code outside the migrations for CREATE, ALTER, DROP, TRUNCATE, GRANT, SET GLOBAL, LOCK TABLES, LOAD DATA, INTO OUTFILE, COPY ... PROGRAM and calls to admin functions. Report how migrations run: by hand, CI, pre-deploy or at boot.
6. MySQL: list triggers, views, routines and events with their definer. Postgres: list which roles own the tables and sequences.
7. Report the server version, whether a statement log or binary log would record a CREATE USER, whether clients use TLS, and whether the deploy healthcheck queries the database.
Then propose a level (1: owner of its own database; 2: rows only, migrations elsewhere; 3: one user per component), with the exact grants, and wait.
PHASE 1 - DESIGN
8. Write the exact SQL for the agreed level. Postgres: include USAGE on the schema, USAGE and SELECT on sequences, and ALTER DEFAULT PRIVILEGES for the role that runs migrations. Postgres 14 or older: plan REVOKE CREATE ON SCHEMA public FROM PUBLIC if nothing relies on it.
9. Choose a connection limit a little above the app's total pool size.
PHASE 2 - CREATE AND TEST
10. Generate a 40-character alphanumeric password with a cryptographically secure generator, into a mode-600 file.
11. Create the user and grants from a mode-600 SQL file through the tunnel, then delete the file.
12. As the new user, on separate connections with no open transaction: read a real app table (must work); show its grants (must match the design exactly); read account password hashes, create a table, create a user, and on Postgres COPY ... TO PROGRAM (each must be refused).
13. Postgres through a trust-authentication tunnel: note that this login does not test the password; the deploy healthcheck will.
Then report, and wait.
PHASE 3 - FIX THE CODE
14. Change every hardcoded user name to read the same environment variable the main connection uses, defaulting to the current value, so nothing changes yet.
15. Commit only those files. After my go-ahead, push, wait for the deployment, and confirm the app still connects as the superuser and the pages using the fixed code still work.
Then wait.
PHASE 4 - STAGE AND SWITCH (only after my go-ahead)
16. Store the new password as a variable on the database service, without deploying it. Confirm the stored value equals the file.
17. Point each app's user and password variables (or its full connection string) at the new user, the password as a reference to the database service's variable, without deploying. Confirm the resolved values equal the expected ones, and that no deployment started.
18. Redeploy each app's existing deployment once. Wait for the result. If it fails, do nothing else: the old container is still serving. Report and wait.
PHASE 5 - VERIFY
19. The connection list shows each app connected as the new user from its new container, and no app connection as the superuser.
20. The new deployment's logs show no access denied, permission denied or command denied errors.
21. Rows created after the switch exist in tables the app writes to.
22. A page that reads the database returns 200 with a cache-busting query string.
23. No app variable references the superuser password any more.
PHASE 6 - CLEAN UP
24. Delete the scratch password, client config and SQL files.
25. Close the tunnel, and check that no SSH process still listens on its port.
26. Give me a short report: what changed, each check and its result, where migrations must now run and with which credential, why the superuser must stay, and the rollback (point the user and password variables back and redeploy).
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.
If the app user can still read and change every row, what is the point?
The data your app already handles is exposed either way. What changes is everything else. A leaked superuser credential can drop tables, read other databases on the same server, read the password hashes of other accounts, create a new user that survives your next password rotation, and on Postgres run shell commands on the database host. A dedicated app user can do none of that. You shrink the damage from a takeover to a data incident.
Do I have to change my code?
Usually not. Most frameworks and ORMs read the user from DATABASE_URL or a MYSQLUSER-style variable. The exception is a user name written into the code itself, often in an old module with its own connection settings. The agent searches for those first, and you deploy the fix while still connected as the superuser, so it changes nothing until the switch.
What about migrations?
Migrations create and alter tables, which a read-and-write user cannot do. Run them with a separate, more powerful credential that does not live in the running app: by hand through a private tunnel, or from a CI job. If your app runs migrations at boot and you cannot change that yet, give the app user schema rights on its own database only. That is still far safer than the superuser.
Will my users notice anything?
No. The new user is created next to the old one, the app variables are staged without deploying, and a single redeploy makes the switch. If the healthcheck queries the database, a mistake fails the deploy and the old container keeps serving. Rolling back is pointing two variables back and redeploying.
Should I delete or rename root or postgres afterwards?
No. Keep it, with a strong password, for administration and migrations. On MySQL, triggers, views and routines often list root as their definer and stop working without it. On Railway, the database tab and the railway connect tunnel use the template credentials too.
Is this worth it for a small side project?
It takes about half an hour once, and it is the cheapest security win available on most apps. Platform incidents, leaked .env files, error trackers that capture environment variables and SQL injection all hand an attacker whatever your app connects as. Make that as little as possible.
Does this work outside Railway?
Yes. The SQL is plain MySQL 8 and later or Postgres 12 and later, and works the same on a VPS, Docker, AWS RDS, Google Cloud SQL, Azure, Neon, Supabase or any other host. Only the way you store the variables and reach the database privately changes; the platform notes below cover that.
Sources