“It works on my machine” is the most expensive sentence in software. When a deploy boots fine locally and breaks on the server, the cause is almost never the code. It is almost always one of a short list of differences between your machine and production, and every one of them is checkable in minutes if you know where to look.
Why the same code behaves differently on a server
A local environment is a curated little lie. It has your dependencies, your environment variables, your working directory, your user. The server has its own versions, its own user, its own paths, and no memory of what you meant. Every difference between those two worlds is a potential failure. The checklist below walks the usual suspects in the order they bite.
The checklist order is deliberate: it mirrors how symptoms surface. Bind address problems bite immediately at boot or first request. Environment problems bite at first config read. Permissions bite at first write. Dependency drift bites at first code path that uses the drifted feature. Logs at the end are the net that catches whatever the earlier checks missed.
1. The bind address and who can reach it
The classic: the app listens on 127.0.0.1, which works locally when you curl localhost, and fails on the server when the reverse proxy or a remote client tries to reach it. The server is up, the process is listening, but on the loopback interface only.
# on the server
ss -ltnp | grep node # shows the listening address
# 127.0.0.1:3000 => loopback only, remote fails
# 0.0.0.0:3000 or :::3000 => reachable
# test like the proxy does, not like localhost
telnet 127.0.0.1 3000 # works
curl http://127.0.0.1:3000/health
If the app is meant to sit behind nginx, bind it to the interface nginx reaches, commonly 127.0.0.1 with nginx on the same box, or the private IP if nginx is elsewhere. Laravel-on-VPS deployments and similar guides show the binding in context.
The subtle variant: the app binds correctly but the reverse proxy upstream block points at the wrong port or the wrong host. The symptom is identical from the outside, a bad gateway or a hang. Checking the listener and then the upstream references in the proxy config, in that order, pinpoints it in minutes.
2. Environment variables that exist only in your head
A config value read from .env on your machine may be absent, stale, or wrong on the server. Database URLs, API keys, debug flags, cache drivers. If the app boots but misbehaves, check what it is actually reading, not what you think it should read.
# dump the config the app sees, not the file you imagine
# laravel
docker compose exec app php artisan config:show database
# node
node -e "console.log(process.env.DATABASE_URL)"
# compare: diff local .env against the server's
scp user@server:/path/.env /tmp/server.env
diff <(grep -v SECRET .env) <(grep -v SECRET /tmp/server.env)
The diff with secrets filtered is the honest version of the check. It finds the app-name vs app-name-prod mismatch, the database host that points at the local docker container name that does not exist on the server, and the debug flag that is on locally and muted on the server, which is often exactly why the failure mode differs.
3. File permissions and the deploying user
Local dev usually runs as your own user with your own permissions. On the server, the app may run as a service user that cannot write its own cache, logs, or uploads directories.
# who runs it
systemctl status myapp | grep -i user
# what can that user touch
sudo -u www-data touch /var/www/app/storage/logs/test.log
# permission denied means ownership or mode mismatch
sudo chown -R www-data:www-data /var/www/app/storage
The same ownership logic explains SFTP permission failures when files are uploaded by one user and read by another. It is the same mental model: every file has an owner and a group, and the process can only write what its user can write.
4. Dependencies that drifted
A lock file keeps local and server dependencies aligned only if the server installs from it. If the deploy runs a bare npm install against a package.json that drifted, or composer without the lock, the server can end up with a different, broken set of versions.
# are they even on the same version?
node -v; npm -v
php -v; composer --version
# compare against local
node -v > /tmp/local-ver && scp /tmp/local-ver user@server:/tmp/ && ssh user@server "diff /tmp/local-ver <(node -v)"
Beyond runtime versions, check that the install step actually used the lock file. A 30-second grep lockfile in the deploy script’s logs tells you whether the server resolved a pinned set or rolled the dice. Reproducible deploys are the whole game here, and the lock-file check is the cheapest proof.
5. The working directory and relative paths
A path like ./src/config.json resolves relative to the process working directory, which differs between your shell and the service manager’s. Commands that assume the current directory fail on the server with baffling “file not found” errors.
# see the app's cwd and any paths it resolves
ps aux | grep myapp # check the cwd column
# systemd: tell the unit exactly where to live
WorkingDirectory=/var/www/app
The systemd fix is the durable one: state the working directory explicitly instead of relying on where the unit happens to start. The same applies to PATH. A script that works in your interactive shell because it inherits a fat PATH breaks under systemd’s minimal environment. List the exact binaries the service needs, or set Environment=PATH=... in the unit.
6. Logs from the service, not the console
Locally, errors print to your terminal. On the server, they may go to journald or a log file, and the actual error is one command away.
journalctl -u myapp -n 100 --no-pager
tail -n 100 /var/log/myapp/error.log
The real error message is almost always here. Starting with this step instead of reading code saves most debugging sessions. When a fix-desperation mode sets in, this is also where the anchor is: the log line that the whole checklist is trying to reach.
The checklist order is deliberate
This checklist is ordered by how symptoms surface, not by preference. Bind address problems bite at boot or the first request. Environment problems bite at the first config read. Permissions bite at the first write. Dependency drift bites at the first code path that uses the drifted feature. Logs come last because they are the net that catches everything the earlier steps missed. Running the list in order usually lands on the cause before step five.
How to make the environments stop differing
The strongest fix for “works locally, fails on the server” is to shrink the difference instead of debugging it. A reproducible runtime, containers or a locked PHP/Node image, removes the dependency-drift class entirely. A startup script that writes the real environment variables, rather than a manually edited .env, removes the config class. A deploy that runs the same entrypoint as local dev removes the working-directory class. Each layer you make identical is a whole category of incidents you stop having.
None of these require an entire platform. Containers on a single VPS, a two-line entrypoint, and a deploy check that echoes the version before starting are enough to eliminate most of the gap. The checklist in this article is for the gap that remains after that.
When the checklist points nowhere: think in layers
If you ran the list and nothing matched, go back to first principles: what layer is the app failing in? DNS and routing, the reverse proxy, the app process itself, the data layer underneath it. Pick the first layer where the failure could live and verify it end to end before moving deeper. Most stubborn cases are a clean failure in an unexpected layer, like the app being fine while nginx points at a stale upstream, which no app-side check will ever catch.
Database migrations and character encoding mismatches
Another subtle reason code breaks on production while running fine locally is database collation and character set drift. A local MySQL or PostgreSQL instance set to utf8mb4_unicode_ci will accept emojis and multi-byte unicode strings, while a server instance left on default latin1 or older utf8 will throw silent truncation errors or fatal syntax crashes:
-- Check server database encoding
SELECT default_character_set_name, default_collation_name
FROM information_schema.schemata
WHERE schema_name = 'app';
-- In PostgreSQL:
SELECT datname, encoding, datcollate
FROM pg_database
WHERE datname = 'app';
Always verify database collation during environment provisioning. If your local migrations assume utf8mb4, encode that requirement explicitly in your migration definitions rather than relying on global server configuration defaults.
The takeaway
- Check the bind address before anything else: loopback-only is the top cause.
- Diff the environment the app reads, not the env you imagine.
- Confirm the service user can write what it needs to write.
- Check dependency versions and the working directory against local.
- Read the service logs first; the error is usually already written down.
“Works locally, fails on the server” is rarely a code bug. It is a checklist that takes ten minutes, and every checkbox is a command you run over SSH. termique is a free SSH manager we build, for jumping straight from the list to the right host. termique.app, if you are curious.