Skip to content
DevOps best practices 5 min read

Deployment Best Practices

A deployment is the act of taking the new version of your application and making it run on a server where real users can reach it. Done badly, a deploy is a stressful, manual, error-prone event that happens at 2 a.m. and sometimes takes the whole site down. Done well, it is a boring, one-click (or zero-click) routine that you trust enough to run in the middle of a workday. This page is a practical checklist for getting from the first kind of deploy to the second, on a real Ubuntu 22.04 / 24.04 LTS server.

Automate every deploy

Manual deploys (someone SSHing in and copy-pasting commands) fail because humans forget steps, run them in the wrong order, or do them differently each time. The fix is to put every single step into a script or a CI/CD pipeline (CI/CD = Continuous Integration / Continuous Delivery, a tool that automatically builds, tests, and ships your code).

When to use this: always, from day one. Even a tiny project benefits. When NOT to: never skip automation because “it’s just a small change” — small changes cause most outages precisely because people get careless.

Start by writing the deploy as a single script so the steps are captured in one place.

#!/usr/bin/env bash
# /opt/myapp/deploy.sh — run on the server, never by hand step-by-step
set -euo pipefail   # stop on any error, undefined var, or failed pipe

APP_DIR=/opt/myapp
RELEASE="release-$(date +%Y%m%d%H%M%S)"

cd "$APP_DIR"
git fetch --quiet origin
git checkout --quiet "$1"          # the commit or tag to deploy
npm ci --omit=dev                  # install exact, locked dependencies
npm run build
sudo systemctl restart myapp
echo "Deployed $RELEASE ($1)"

Make it executable and run it with a tag instead of “whatever is on main”:

sudo chmod +x /opt/myapp/deploy.sh
sudo /opt/myapp/deploy.sh v1.4.2

Output:

Deployed release-20260615T140210 (v1.4.2)

Use set -euo pipefail at the top of every deploy script. Without it, a failed npm run build is ignored and you happily restart the app on broken code.

Make deploys repeatable

Repeatable means: the same input always produces the same result, on any server. The enemy of repeatability is “it works on my machine.” Pin your versions.

TechniqueWhat it doesWhy it matters
npm ci (not npm install)Installs the exact versions in package-lock.jsonTwo deploys install identical dependencies
Deploy a git tag, not a branchv1.4.2 is frozen; main keeps movingYou always know exactly what shipped
Pin the runtimee.g. Node 20.x via .nvmrc or a system packageApp behaves the same everywhere
Use systemd to run the app/etc/systemd/system/myapp.serviceSame start command, restart policy, and user every time

A minimal systemd service file makes the run command itself repeatable:

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target

[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /opt/myapp/dist/server.js
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp

Deploy small and often

A “big bang” deploy that bundles three weeks of changes is risky: when it breaks, you have no idea which of the hundred changes caused it. Small, frequent deploys are safer because each one changes little, so problems are easy to spot and easy to undo.

  • Merge and deploy in small increments — ideally several times a day.
  • Hide unfinished work behind a feature flag (a config switch that turns a feature on or off without redeploying) instead of a long-lived branch.
  • The smaller the diff, the faster the review and the lower the blast radius.

Keep config in the environment, not the code

A golden rule (from the “Twelve-Factor App” methodology) is that anything that changes between environments — database passwords, API keys, hostnames — lives in environment variables, never in your source code. This keeps secrets out of git and lets the same build run in staging and production unchanged.

On Ubuntu, store them in a root-only file that systemd loads:

sudo install -m 600 -o root -g root /dev/null /etc/myapp/env
sudoedit /etc/myapp/env
# /etc/myapp/env  (permissions 600, never committed to git)
NODE_ENV=production
DATABASE_URL=postgres://myapp:s3cr3t@localhost:5432/myapp
PORT=3000

The EnvironmentFile=/etc/myapp/env line in the service unit (above) injects these into the app at start. Add the file pattern to .gitignore so it can never be committed.

Set the secrets file to mode 600 (readable only by root). A world-readable env file is one cat /etc/myapp/env away from leaking your database password to any local user.

Health-check before serving traffic

Never send users to a process that has started but isn’t actually ready (database still connecting, cache still warming). Expose a /health endpoint that returns HTTP 200 only when the app is genuinely ready, and check it as part of the deploy.

# Wait up to 30s for the new process to report healthy
for i in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:3000/health >/dev/null; then
    echo "Healthy after ${i}s"; exit 0
  fi
  sleep 1
done
echo "App failed health check"; exit 1

Output:

Healthy after 4s

If you run a reverse proxy (a server such as Nginx that sits in front of your app and forwards requests to it), point its upstream at the app only after the health check passes, so users never hit a half-started instance.

Always have a fast rollback

A rollback is going back to the previous known-good version. The single most calming thing about a deploy is knowing you can undo it in seconds. Because you deploy tags (not branches), rolling back is just deploying the previous tag:

sudo /opt/myapp/deploy.sh v1.4.1   # the last version that worked
StrategyHow rollback worksWhen to use
Re-deploy previous tagRun deploy script with the old tagSimple apps; rollback in ~1 minute is fine
Symlinked releasesRepoint a current -> symlink to the old release dirYou want near-instant rollback, no rebuild
Blue-greenKeep old version running; switch proxy backZero-downtime, instant rollback; needs 2x capacity

Test that rollback actually works before you need it. A rollback path you’ve never run is not a rollback path.

Best Practices

  • Script every deploy and run it from CI/CD — never hand-type production steps.
  • Deploy immutable git tags with npm ci, so each release is reproducible.
  • Ship small changes often and hide WIP behind feature flags.
  • Keep all secrets and per-environment config in env files (mode 600), out of git.
  • Health-check the new process and only route traffic once it reports ready.
  • Keep a one-command rollback to the previous tag, and rehearse it regularly.
  • Deploy to a staging server first; never let production be the first place new code runs.
Last updated June 15, 2026
Was this helpful?