Deployment Error Troubleshooting Handbook
2026-09-23 · 8 min read
Deployment errors look scary, but there are only so many kinds. This is a troubleshooting manual, not a tutorial — build one habit first: write down the full original error message (screenshot or copy it) before touching anything. The first line of many errors already states the cause; skipping it to search for folk remedies is the most common detour beginners take.
Browser can't open the site: check these three layers
- DNS: ping the domain and check the returned IP is your server's. Freshly changed records take minutes to hours
- Security group ports: check the provider console that 80/443 are open. This is the most common one — eight out of ten beginners get stuck here
- The service itself: curl localhost on the server to see if the app actually started. If not, keep reading below
Node version mismatch
When the error mentions engines, EBADENGINE, or throws inexplicable syntax errors, first check the Node version the project requires (the delivery package docs say, and there's usually an .nvmrc file). Install the matching version with nvm and try again. Don't assume installing the “latest” on the server settles it — different major versions simply won't run, that's normal.
Database won't connect
First check DATABASE_URL is written correctly — the format is fixed:
postgresql://username:password@host:5432/dbnameThen confirm three things: the database service is running, the username and password are right, and npx prisma generate has been run (skipping it throws a “client not generated” error). If the password has special characters, URL-encode them: @ becomes %40, # becomes %23 — a very sneaky pit.
Build killed for lack of memory
1GB boxes running npm run build often go quiet mid-build and the process is gone — that's the system killing it for lack of memory. The emergency fix is adding swap:
# add a 2G swap partition, for emergencies
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfileThe long-term fix is building locally and uploading the artifacts, or moving to a 2GB machine. Slow builds and killed builds are different things: slow can wait, killed means you must add memory.
502 Bad Gateway
502 means the reverse proxy (Nginx/Caddy) couldn't find the app behind it. Check two things: whether the port in the proxy config is the same one the app actually listens on, and whether the app process is still alive. Nine times out of ten the app crashed and never came up, or the port was typed wrong.
Where to read the logs
When the error message isn't enough, read the logs — the answer is basically always in there. However you started the service, that's where you look: pm2-started → pm2 logs, systemd-started → journalctl, docker-started → docker logs. Read the last few dozen lines, scanning bottom-up for the first ERROR.
The ultimate fix: paste the error into an AI
The EazyOPC delivery package has a troubleshooting prompt — copy it to Claude Code or Codex, then paste the full original error, how far you got, and what OS the server runs, and let it look. The more complete the info you give the AI, the better it locates the problem; paste half an error and it can only guess.
npm install stuck or failing
Stuck for over five minutes with no movement is basically a network problem: the server is overseas and the official npm registry is slow or blocked. Switch to a domestic mirror — one command. For errors, read the first line: EACCES is a permissions issue — don't sudo npm, install node as a normal user; version conflicts — look at the package name in the error. The nuclear option: delete node_modules and package-lock.json and reinstall. Before reinstalling, confirm the node version is right, or the reinstall is wasted.
npm config set registry https://registry.npmmirror.com
rm -rf node_modules package-lock.json && npm installPort in use: EADDRINUSE
EADDRINUSE or “port already in use” at startup means something is already listening on that port. Two possibilities: a site you started earlier is still up, or another program took it. Use lsof to find who; if it's your own old process, kill it, or switch ports. Another common case: pm2 has two same-named instances fighting each other — see the duplicate in pm2 list and delete one.
lsof -i :3000
kill PID
pm2 listWrong timezone: cron jobs always run at odd hours
Servers default to UTC. Your backup cron says “3 AM,” but it runs at 3 AM UTC — 11 AM Beijing time, right in the middle of user activity, dragging the site down. Run date first to see what time the server thinks it is; set the timezone if it doesn't match. For Node programs dealing with time, you can also add TZ to the environment variables. If log timestamps don't line up later, check the timezone first — nine times out of ten that's it.
date
timedatectl set-timezone Asia/ShanghaiHTTPS certificate expired
The browser suddenly reports an expired certificate — don't panic. certbot certificates last 90 days and rely on a cron job to renew; if the job wasn't set up right they expire. Renew manually once, then investigate why the cron job didn't run. With Caddy you usually don't need to worry — it renews automatically — but only if port 80 is reachable from the outside: if the security group has port 80 closed, renewal fails. After renewing, hard-refresh the browser; cached old pages will lie to you.
certbot renew
# dry-run the renewal to verify the cron job works
certbot renew --dry-runBuild succeeded but the page is blank
npm run build passed, the site opens but the page is blank — the most infuriating kind. Check the browser console errors first: the red text tells you which request failed. Two usual causes: environment variables not set (the API URL is empty so the page gets no data), or the frontend/backend addresses are wrong (calling localhost, which obviously doesn't exist in production). Walk through them one by one — eight times out of ten it's the environment variables.
Database migration errors
prisma db push or migrate errors — first read what the error says about the table's existence: “table already exists” usually means a previous push died halfway; connect to the database manually and check whether the table was actually created. When unsure, back up the data first, then drop and rebuild. Remember the order: backup first, act second. Database operations have no undo button.
One last habit: after every troubleshooting session, write three lines — symptom, cause, fix. After half a year that's your own troubleshooting manual, more useful than any tutorial, because every entry was earned with real money. Next time it happens, you locate it in three seconds instead of starting from scratch.
git pull conflicts: never edit code on the server
git pull reporting conflicts at deploy time means someone edited code directly on the server. The rule: never edit code on the server — edit locally, commit, then pull. Already changed it? Commit first if the change matters; git stash or git reset away the unimportant ones. The cleanest approach: deploy with a fresh clone and delete the old directory entirely, eliminating any “one uncommitted line on the server.”
Permission issues: EACCES
When the error has EACCES or “permission denied,” don't reach for sudo first. Things installed with sudo npm belong to root, and running as a normal user later breaks everywhere. The right way: install node as a normal user (nvm into the user directory) and own all project files as that user. For an already-messed-up setup, chown the project directory back to the right owner. Remember: permission problems are solved by “straightening out ownership,” not by brute-forcing sudo — sudo only buries the pit deeper.
Build ran out of memory: heap out of memory
npm run build dying halfway with “JavaScript heap out of memory” means Node's default heap is too small. Temporarily raise it and rebuild:
NODE_OPTIONS=--max-old-space-size=1536 npm run build1536 is for 2GB machines; on 1GB machines use 768 plus swap. That only gets the build through — a band-aid; the cure is more memory or building locally and uploading artifacts. Passing the build doesn't mean it can run — leave enough runtime memory too; don't pin the box at 100%.
CORS errors: cross-origin
The browser console reporting CORS / Access-Control-Allow-Origin means the frontend called an API on another domain and the browser refused. Fine in local dev, broken the moment it launches — classic. The root cause is inconsistent frontend/backend addresses: the easiest fix is having the reverse proxy serve the API and the pages under the same domain — no more cross-origin. If you insist on cross-origin, configure the allowed domains properly on the backend; don't lazily use a wildcard — a wildcard on payment-related endpoints is running naked.
Environment variables changed but not taking effect
You edited .env but the site looks the same — two causes: first, you didn't restart; env vars are read once at startup, so any change requires restarting the process. Second, pm2-started apps: pm2 caches environment variables, so restart with pm2 restart --update-env. How to verify: after restarting, log a key variable (redacted) and confirm it reads the new value. Don't rely on “I think it took effect” — confirm via logs.
DNS changes not taking effect
You changed the A record but ping still shows the old IP: first check the TTL — that's how long propagation takes. Then check local DNS caching — try another network or your phone's mobile data. Use dig to see whether the returned IP is the new one; dig bypasses local caches and reads the authoritative answer. A few minutes of no effect right after a change is totally normal — have some water and check again. If it still hasn't taken effect after several hours, verify the record wasn't added in the wrong place (e.g., under a different domain).