How to Deploy Docker Containers to a Linux Server or VPS
For one app on one server, you do not need Kubernetes. Docker Engine, a Compose file, a reverse proxy for TLS and a small deploy script cover most cases, whether the machine is a VPS, a bare-metal box, a Droplet or an EC2 instance. This guide builds that setup and points out where it stops being enough.
Prepare the server
Install Docker Engine and the Compose plugin from Docker's repository (Linux install guide). Then create a deploy user and set up the firewall:
sudo adduser deploy
sudo usermod -aG docker deploy
sudo mkdir -p /srv/my-app && sudo chown deploy: /srv/my-app
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
Use SSH keys for deploy and disable password logins in /etc/ssh/sshd_config (PasswordAuthentication no). Turn on automatic security updates (unattended-upgrades on Debian and Ubuntu, dnf-automatic on the RHEL family). Membership in the docker group is root-equivalent, so treat the deploy user's key accordingly.
One firewall caveat: ports published with -p or ports: bypass UFW and firewalld, because Docker manages iptables itself. The safe pattern is to publish only the reverse proxy's 80 and 443 and keep everything else on internal Docker networks. If you must publish something for local use, bind it to loopback: 127.0.0.1:5432:5432.
Get the image onto the server
Through a registry (recommended). CI builds and pushes a versioned image, and the server pulls it:
docker build -t ghcr.io/example/my-app:1.2.0 .
docker push ghcr.io/example/my-app:1.2.0
On the server, run docker login ghcr.io once with a read-only token.
Without a registry. Stream the image over SSH:
docker save ghcr.io/example/my-app:1.2.0 | gzip | ssh deploy@203.0.113.10 'gunzip | docker load'
Building on the server from a git checkout also works for small projects. It uses the server's CPU and memory during builds, and the server needs your source code.
If you build on an ARM Mac for an x86 server, add --platform linux/amd64, or the container fails with exec format error.
The Compose file
Put two files in /srv/my-app: compose.yaml and a .env that holds only the image tag (APP_TAG=1.2.0).
services:
app:
image: ghcr.io/example/my-app:${APP_TAG}
restart: unless-stopped
env_file: app.env
volumes:
- app-data:/var/lib/my-app
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 3
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging:
driver: local
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
- app
volumes:
app-data:
caddy-data:
And the Caddyfile:
app.example.com {
reverse_proxy app:8080
}
Why each piece is there:
restart: unless-stoppedrestarts the container after a crash and after a reboot, as long as the Docker service is enabled. You do not need a systemd unit per container.- Named volumes keep data across container replacements. Anything written outside a volume is lost on the next deploy. Back up
/var/lib/docker/volumesor dump databases on a schedule. - The healthcheck lets Docker and
docker compose up --waitknow when the app is actually serving. Use a command that exists in your image (wget,curlor a small binary of your own). - Resource limits stop a leak in one container from taking the whole server down. Compose applies
deploy.resources.limitswithout Swarm. - The
locallog driver rotates logs, so they cannot fill the disk. - Caddy is the only service with published ports. It gets and renews Let's Encrypt certificates on its own once DNS points at the server. Nginx with certbot, or Traefik, work too.
Secrets go in app.env (chmod 600, not in git), never in the image.
Start it:
cd /srv/my-app
docker compose up -d --wait
docker compose ps
docker compose logs -f app
Deploy and roll back
A deploy is "set the tag, pull, recreate". Run it from CI or your laptop over SSH:
#!/usr/bin/env bash
set -euo pipefail
TAG="$1"
ssh deploy@203.0.113.10 "
cd /srv/my-app &&
sed -i 's/^APP_TAG=.*/APP_TAG=$TAG/' .env &&
docker compose pull app &&
docker compose up -d --wait &&
docker image prune -f
"
./deploy.sh 1.3.0 deploys a version, and ./deploy.sh 1.2.0 rolls back. That only works because every build has its own immutable tag. --wait makes the command fail if the new container never becomes healthy, so CI reports the failure instead of a green build in front of a broken app.
To run commands against the remote daemon from your machine without SSHing in each time, create a Docker context:
docker context create prod --docker "host=ssh://deploy@203.0.113.10"
docker --context prod ps
docker --context prod logs my-app-app-1
About downtime
docker compose up -d stops the old container before starting the new one, so each deploy has a gap of a few seconds. Often that is acceptable. If not, there are two common options on a single host:
- Blue-green behind the proxy. Run the new version next to the old one under a different name, wait until it is healthy, switch the proxy upstream, then remove the old container. It works, but you maintain the script yourself.
- Docker Swarm on one node.
docker swarm init, thendocker stack deploy -c compose.yaml my-appwith this in the service definition:
deploy:
replicas: 2
update_config:
order: start-first
failure_action: rollback
Swarm starts the new task, waits for its healthcheck, and only then stops the old one. It rolls back if the update fails. Note that docker stack deploy ignores build: and depends_on and does not read .env, so the images must come from a registry and variables must be exported in the shell.
When you need several servers, autoscaling or self-healing across machines, it is time for a managed container service or Kubernetes.
Image practices that make deploys boring
- Multi-stage builds and small base images, so pulls are fast and the image has less to patch.
- A non-root
USERin the Dockerfile. - One immutable version tag per build. Never deploy
latest. - Configuration through environment variables, so the same image runs in staging and production.
docker system dfand an occasionaldocker image pruneto keep old images from filling the disk.
Quick reference
# once
sudo usermod -aG docker deploy
docker login ghcr.io
# deploy / rollback
sed -i 's/^APP_TAG=.*/APP_TAG=1.3.0/' .env
docker compose pull app && docker compose up -d --wait
# inspect
docker compose ps
docker compose logs -f app
docker stats
# no registry
docker save IMAGE | gzip | ssh deploy@HOST 'gunzip | docker load'
