I recently took an API from "works perfectly on my laptop" to "live on its own domain over HTTPS". On the laptop it was in good shape: hundreds of automated tests passing, static analysis clean, and a full Postman demo that ran green twice in a row.
Getting it live took around twenty failures.
This guide is the full record, written so it applies to any similar project: every step in the order it has to happen, every error exactly as it appeared, what actually caused it, and what fixed it — including the fixes that didn't work and the decisions I got wrong the first time. My stack was PHP, PostgreSQL and Docker Compose behind nginx, but almost every lesson carries over to a Node, Python, Go or Ruby app deployed the same way.
One pattern runs through nearly all of it, so it is worth saying up front:
The error almost never appears at the step that caused it. A wrong user ID showed up as a package-manager error. A missing database row showed up as a 401. A changed port number showed up as a Python stack trace. When a step fails, suspect the step before it.
Contents
The situation this guide covers
How the Compose files are arranged
Part 1 — Before touching the server
Part 2 — Three decisions to make before deploying
Part 3 — On the server, step by step
Part 4 — What I would tell myself before starting
Part 5 — Before you share the URL
The go-live checklist :
The commands below use these placeholders. Set them for your own project:
SERVER=deploy@203.0.113.10 # your SSH login
DOMAIN=example.com # your domain
PORT=8090 # the host port the app listens on (loopback only)
The examples assume the project lives in a folder called myapp on the server. That name matters more than it looks: Docker Compose names images and volumes after the folder, so a folder called myapp produces an image called myapp-app and a volume called myapp_app_var. Substitute your own.
How the Compose files are arranged
A common and sensible layout:
• docker-compose.yml — the base definition.
• docker-compose.override.yml — development settings. Docker loads this automatically whenever you run docker compose with no -f flags.
• docker-compose.prod.yml — the production overlay: the database publishes no port, the app binds to 127.0.0.1 only, the filesystem is read-only, and containers restart on failure.
So every production command needs both files named explicitly. An alias saves typing and mistakes:
alias dcp='docker compose -f docker-compose.yml -f docker-compose.prod.yml'
Every dcp below means exactly that.
Lesson: forget the -f flags on a server and Docker silently loads the development override instead — which, in most setups, publishes your database to a host port. That is a security problem, not a cosmetic one.
Part 1 — Before touching the server
Most of what went wrong on the server could have been caught on the laptop. These are the checks worth running first.
1. Build the production image locally, and prove it boots
Production rarely behaves exactly like development, and automated tests almost always run in development mode.
In my case, production compiled the dependency-injection container for speed, while development didn't. One factory closure captured a variable — something the compiler refuses to handle. Locally, nothing failed. In production, the very first request would have died before reaching a single route.
The same trap exists wherever production caches or compiles configuration: Laravel's config:cache, Symfony's compiled container, framework production builds that inline environment variables.
Fix: pass the value in as a proper container parameter instead of capturing it, and add a script that boots the app exactly the way production does and resolves every route. Run it in CI.
dcp build
docker run --rm myapp-app php bin/container-check.php # whatever proves your app boots in production mode
Lesson: if production has a "compiled", "cached" or "optimised" mode, exercise that mode before you deploy — and in CI, every time.
2. Rehearse the full demo — twice in a row
One successful run proves less than it looks. Running the whole test collection a second time, immediately, found a real bug.
The sign-in rate limiter allowed five attempts and then one every thirty seconds — deliberately tight, because it protected a short numeric PIN. But it was applied to every request to the token endpoint, and when a request carried no user identifier it fell back to keying on the IP address. Admin sign-ins carried no identifier. An office behind one internet connection shares one IP address. The sixth colleague to sign in within half an hour would have been refused.
Fix: apply the tight limit only to the one kind of request that presents a guessable secret.
Lesson: a second consecutive run catches everything that "works the first time": rate limits, single-use tokens, and state left behind by the first run.
3. Don't bake credentials into test tooling
My Postman collection originally shipped with access tokens already inside it. That had three costs:
• On import, Postman flagged the file for containing secrets.
• The tokens expired an hour after being generated, so the file quietly went stale.
• The tokens came from a development tool that grants whatever permissions you ask for — broader than a real user could actually obtain. The demo was showing things no real user could do.
Fix: make the collection's first folder perform the real sign-in flow (for OAuth, the authorization-code flow with PKCE) and capture tokens while it runs. Nothing signed is stored in the file.
Doing this immediately exposed two more hidden problems. The demo user accounts had a placeholder password hash — so real sign-in could never have worked — and the OAuth client existed only because somebody had once inserted it by hand into one database.
Also give the collection a stable ID. Postman recognises a re-import by the collection's _postman_id. Mine was a fresh random UUID every time, so re-importing created duplicates instead of offering Replace. Deriving it deterministically (a UUID v5 from a fixed namespace) fixed that.
Lesson: test tooling that bypasses real authentication hides authentication bugs. And "it exists in my database" is not the same as "it exists".
4. Make seed scripts fail loudly
After I added a table with a foreign key pointing at the users table, the demo seed's clean-up step began to fail — but psql without ON_ERROR_STOP prints the error and exits successfully. The script reported success while deleting nothing, and the demo then pointed at the previous run's data. The symptom looked exactly like a permissions bug.
Fix: set -euo pipefail at the top of every shell script, and psql -v ON_ERROR_STOP=1 for every database call.
Lesson: a script that can fail silently eventually will, and the symptom will look like something else entirely.
5. Build a database from empty
I found this one on the server, but it belongs here.
The schema had a country lookup table described as "reference data" — and nothing ever populated it. Every developer's database had the rows only because someone had added them by hand long ago. On the fresh server, creating the very first record that referenced a country failed with a foreign-key violation.
Fix: a migration that inserts the reference data, written so it is safe to run against databases that already have the rows.
Lesson: before going live, drop a database, migrate it from scratch, and seed it. Anything that only works on a database that has been lived in will break on day one.
6. Put generated files where the person using them will look
The generated Postman collection was written deep inside the project directory. Over a remote-desktop session that path was, in practice, unfindable. I moved it to a single fixed folder on the Desktop, behind one variable that every script reads — and stopped moving it.
Lesson: one output path, defined in one place, printed in full every time.
7. Commit before deploying — and check what you are committing
If the project isn't under version control yet, check before the first commit:
git init -b main
git add -A
git diff --cached --name-only | grep -E '(^|/)\.env$|\.pem$|\.key$|private' && echo "STOP: secrets staged"
Make sure .gitignore covers .env, private keys, dependency folders and runtime directories, and read the first commit's author and message before you push it. Rewriting a commit after pushing takes an amend, git push --force-with-lease, git reflog expire --expire=now --all and git gc --prune=now — and even then a hosting provider can keep the old commit reachable by its hash for a while. Checking first is far cheaper.
Your email address will not be published. Required fields are marked *
Comments