a month ago
Hi Railway team,
We recently rebuilt our PostgreSQL setup and would appreciate a sanity check against Railway’s recommended practices. Our goal is the simplest Railway-native setup that provides secure customer-data handling, least-privilege access, reliable migrations, staging/production isolation, and a solid foundation for future SOC 2 readiness—without unnecessary operational complexity.
Our current design:
- PostgreSQL 18 using Railway’s SSL-enabled template, with isolated databases and volumes per environment.
- Applications connect over Railway private networking.
- Separate least-privilege roles for migrations and application runtime; production has no QC/read-only login.
- Database passwords are sealed Railway variables, with consumer services using variable references rather than duplicated secrets.
- Migrations run through a one-shot
db-migrateservice. GitHub Actions triggers a Railway source redeploy when migrations, schema, database configuration, or relevant dependencies change, then requires a terminal successful deployment. - Workflow-only or unrelated changes are successful no-ops.
- Runtime services do not run migrations during startup.
For authenticated TLS, the generated server certificate has localhost as its CN/SAN while the network destination is the service’s private Railway address. To retain verify-full, we use:
- Node: the private destination address, the environment-specific public CA,
rejectUnauthorized: true, andservername: localhost. - libpq:
PGHOSTADDRfor the private destination,PGHOST=localhost,sslmode=verify-full, and the environment-specific root certificate. - Only the public CA is distributed to consumers; private keys remain inside the PostgreSQL volume.
Could you please confirm:
- Is this migration and TLS configuration correct and safe for ongoing production use?
- Is authenticated
verify-fullTLS materially useful on Railway private networking, particularly for defense in depth and future SOC 2 readiness? - Is the private-address/
localhostcertificate split the intended approach for Railway’s SSL PostgreSQL template, or is there a simpler recommended configuration? - How should customers detect and prepare for server certificate or CA rotation to avoid downtime?
- Is there any Railway-native migration mechanism or PostgreSQL pattern you would recommend over our one-shot migration service?
We are mainly looking to ensure we are following Railway conventions rather than maintaining unnecessary custom machinery. No credentials, private keys, project IDs, or customer data are included here.
Thanks!
2 Replies
a month ago
This thread has been opened as a public bounty so the community can help solve it. The thread and any further activity are now visible to everyone.
Status changed to Open Railway • about 1 month ago
17 days ago
Short answer: this is a sound production design. I would keep the role/environment separation, but simplify the migration path where possible and explicitly own the lifecycle of the template-generated CA.
- Private networking and TLS
Railway private networking is already environment-isolated and encrypted with WireGuard, so it provides confidentiality in transit without exposing PostgreSQL publicly:
https://docs.railway.com/networking/private-networking
Adding PostgreSQL TLS is still reasonable defense in depth. It protects against an incorrect endpoint, DNS/configuration mistakes, and some failures inside the trust boundary. However, verify-full is not by itself a SOC 2 requirement or a substitute for access controls, audit evidence, backup/restore testing, secret rotation, and change management.
The current postgres-ssl template really does generate a server certificate whose CN and SAN are localhost:
https://github.com/railwayapp-templates/postgres-ssl/blob/main/init-ssl.sh
Therefore both of your connection patterns are technically valid:
- Node connects to the private Railway address but passes servername: "localhost" for certificate identity validation.
- libpq uses PGHOSTADDR for the actual network destination and PGHOST=localhost for verify-full hostname validation.
In both cases, the TCP route still goes to the private Railway address; localhost is used only for certificate identity checking. This provides CA validation plus hostname validation, but it is a template-specific workaround rather than a stable Railway private-DNS identity contract. Document it, cover it with a connection smoke test, and retest when changing the Postgres image.
For the simplest Railway-native setup, using the referenced DATABASE_URL/PGHOST on the private network is the normal path:
https://docs.railway.com/databases/postgresql
If your threat model does not require hostname verification inside the encrypted private mesh, verify-ca (or the client equivalent that validates the supplied CA without matching the private DNS name) avoids the localhost split while still authenticating the issuing CA. Keep verify-full only if you deliberately accept the template coupling.
- Certificate and CA rotation
The postgres-ssl image currently documents automatic certificate renewal during restart/redeploy when expiry is near:
https://github.com/railwayapp-templates/postgres-ssl
That does not make consumer trust rotation automatically safe when applications pin an environment-specific root CA. I recommend treating that CA as a lifecycle-managed secret:
- pin the production image to a reviewed version;
- monitor the notAfter value of both the server certificate and distributed root CA, alerting well before the renewal window;
- test a planned rotation in staging;
- deploy the updated trust bundle to consumers before switching the database certificate whenever possible;
- keep a short overlap with both old and new roots if your client supports a CA bundle;
- run a verify-full/verify-ca smoke test after every database restart or image update.
If you need guaranteed overlap and deterministic rotation timing, fork/customize the image or use an externally managed internal CA. The stock template should not be assumed to provide a complete zero-downtime PKI workflow for separately distributed CA material.
- Migrations
Your one-shot db-migrate service is valid, particularly if you want an independent deployment record and explicit GitHub Actions gating. Railway also has a native pre-deploy command intended for database migrations:
https://docs.railway.com/deployments/pre-deploy-command
It runs after the image is built and before the application deployment, has environment variables and private-network access, and blocks the deployment on a non-zero exit. It runs in a separate container, is not retried, and has no mounted volume. For ordinary schema migrations, this is simpler than a dedicated service.
Whichever mechanism you keep:
- make migrations idempotent and acquire the migration tool's lock/advisory lock;
- use expand/contract changes so old and new application versions can overlap safely;
- ensure only the migration role can perform DDL;
- keep migrations out of runtime startup;
- fail closed on migration errors and record the applied schema version.
- Overall assessment
The environment isolation, reference variables, separate migration/runtime roles, and no-startup-migrations policy are all good choices. I would call this production-appropriate once backup restore drills, monitoring, role grants, secret/CA rotation, and migration rollback/forward-fix procedures are also tested. The main decision is whether the additional verify-full hostname check is worth owning the localhost-certificate coupling; on Railway's encrypted private mesh, verify-ca is often the cleaner balance.
17 days ago
- correct/safe? Yeah, this is right. Just double-check rejectUnauthorized: true is actually respected end-to-end in your Node pool config and not overridden somewhere — that's the usual footgun with this setup.
2 . is verify-full worth it over private networking? Yes. "Private" on Railway just means an internal overlay network between your project's services, not full isolation — so TLS still protects against a misconfigured neighbor on that same mesh. And for SOC 2, auditors generally want encryption-in-transit regardless of network trust boundary, so this isn't just theater.
3 . is the localhost/private-address split intended? Yes — the postgres-ssl template generates the server cert with CN=localhost because it can't predict your private DNS name ahead of time. Dropping to verify-ca would dodge the mismatch but gives up hostname verification. What you're doing (keep verify-full, manually pin servername) is the correct route, not a hack.
4 . rotation without downtime? You're actually already covered — the template checks cert expiry on every redeploy/restart and auto-renews if it's expired or within 30 days of expiring, so as long as you (or a scheduled redeploy) touch the service periodically, you won't hit a hard expiry. Worth adding a monitoring check anyway (handshake + expiry alert) as a backstop, since redeploys aren't guaranteed to happen on any particular cadence.
5 . better migration pattern? Railway actually has a native primitive for exactly this: Pre-Deploy Command, set per-service in settings. It runs in a separate container between build and start, has access to your env vars (including DATABASE_URL) and private networking, and blocks the deploy from proceeding if it fails — which gets you what your one-shot db-migrate service does, but built in, with less moving parts (no separate service/GitHub Actions trigger to maintain). One caveat: it doesn't have volume access, so if your migrations ever need filesystem state beyond the image, that'd need to stay in the build step. Worth checking out: docs.railway.com/deployments/pre-deploy-command