How to Deploy Applications with Docker Compose on a VPS
A practical production guide to Docker Compose v2 on Ubuntu: install the engine, write a Compose file with networks and volumes, handle secrets, put a reverse proxy in front, update with zero-panic rollouts, and back up named volumes.

Docker Compose is the usual way to run a small stack on one VPS: a web app, a database, a Redis cache, and a reverse proxy, described in one YAML file. It is not Kubernetes. You will not get multi-node failover. You will get a repeatable, reviewable setup that you can recreate on a new Hiddence VPS in minutes — which is exactly what most side projects and small products need.
This article assumes you want something closer to production than docker run on a Friday night. We will install Docker Engine and the Compose plugin, create a non-root deploy user, write a Compose file with healthchecks and restart policies, keep secrets out of Git, publish only the reverse-proxy ports to the internet, and define an update and backup routine. The example stack is a typical web app plus PostgreSQL plus Caddy, but the same pattern works for Node, PHP, Python, or a bunch of workers.
Why Compose on a single VPS still makes sense
People jump to Kubernetes because it is fashionable, then spend the weekend on YAML for one website. Compose stays readable. You version the file, you document environment variables, and you can diff a change before you apply it. Isolation is good enough: a compromised app container should not hold the database UNIX socket if you used networks and least-privilege users. Resource limits stop one memory leak from freezing the whole VPS.
- One file describes the whole stack
- Named volumes survive container recreation
- Internal Docker networks keep Postgres off the public internet
- restart: unless-stopped covers most reboots
- Easy to copy to a second VPS when you outgrow the first
Requirements
Use a VPS with enough RAM for the app plus Postgres. A 2 GB plan is a realistic minimum for app + database + proxy. Ubuntu 24.04 is assumed. Do not install Docker from a random Snap without reading what it does to cgroups; the steps below use Docker's official apt repository.
- Ubuntu 22.04/24.04, root or sudo
- 2 GB RAM recommended (1 GB only for tiny stacks without Postgres)
- A domain pointing to the VPS if you want automatic HTTPS
- Git or another way to copy the project onto the server
Step 1: Install Docker Engine and Compose v2
Compose v2 is a plugin invoked as docker compose (with a space), not the old docker-compose Python binary. Install both from Docker's repository so you get current security patches.
ssh root@YOUR_VPS_IP
apt update && apt -y install ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker version
docker compose versionStep 2: Deploy user and directory layout
Running Compose as root works, but a dedicated user with membership in the docker group is cleaner. Put the project in /opt or /srv, not in /root, so backups and permissions are obvious. Never world-write the directory that contains .env.
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
mkdir -p /srv/app
chown deploy:deploy /srv/app
chmod 750 /srv/app
# Log in as deploy for the rest of the file editing:
# su - deploy
# cd /srv/appStep 3: Write a production-minded Compose file
The file below is a template. Replace the app image with yours. Postgres is not published to 0.0.0.0:5432 — only Caddy is published on 80 and 443. Healthchecks stop a reverse proxy from sending traffic to an app that is still migrating the database. Pin image tags; latest is how surprise breakage happens.
# /srv/app/compose.yaml
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
app:
condition: service_healthy
app:
image: ghcr.io/example/webapp:1.4.2
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 10
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- backend
networks:
frontend: {}
backend: {}
volumes:
pgdata:
caddy_data:
caddy_config:Step 4: Secrets, .env and Caddyfile
Put passwords in .env on the server, chmod 600, and never commit that file. Generate passwords with openssl rand -base64 32. The Caddyfile only needs to reverse-proxy to the app service name on the Docker network.
# /srv/app/.env (permissions 600)
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-long-random
POSTGRES_DB=app
# plus any APP_SECRET / NEXTAUTH_SECRET your image needs
# /srv/app/Caddyfile
app.example.com {
encode gzip
reverse_proxy app:8000
}
chmod 600 /srv/app/.env
cd /srv/app
docker compose up -d
docker compose ps
docker compose logs -f --tail=100Step 5: Firewall, logs and resource limits
UFW should allow 22, 80 and 443 only. Docker sometimes bypasses UFW for published ports; if you need a strict host firewall, look up the current Docker + UFW interaction for your Ubuntu version or publish ports only on 127.0.0.1 and put a host Caddy in front. Set memory limits in Compose so Postgres cannot eat the VPS.
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Optional in compose.yaml under app or db:
# deploy:
# resources:
# limits:
# memory: 512M
# Logs (do not let json-file grow forever):
# { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
# in /etc/docker/daemon.json then: systemctl restart dockerStep 6: Updates and rollbacks
The boring update that works: pull new images, up -d, watch healthchecks, keep the previous tag in git so you can pin back. Do not docker compose down on a production database unless you intend to stop accepting traffic. down -v deletes volumes — that is a wipe, not a restart.
cd /srv/app
git pull # if the Compose file lives in git
# Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker compose ps
# Rollback: set the old tag, pull, up -d again
# NEVER: docker compose down -v # this deletes named volumesBack up named volumes
A VPS snapshot is good. A logical Postgres dump is better if you need to restore onto another machine. Schedule this with cron. Test a restore once, or you do not have backups — you have files you hope are backups.
# Postgres dump while the db container is running:
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gz
# Copy off-box
# scp /var/backups/app-*.sql.gz backup-host:~
# Restore sketch (maintenance window):
# gunzip -c app-2026-08-19.sql.gz | docker compose exec -T db psql -U app appTroubleshooting
Read docker compose ps and docker compose logs service. Image pull failures are usually ghcr/docker hub rate limits or a private image without a login. 502 from Caddy means the app is not healthy or the proxy hostname is wrong (use the Compose service name, not localhost, from inside the Caddy container). Permission errors on volumes often mean the image runs as uid 1000 while the host directory is root.
- compose: command not found — you installed the old binary name; use docker compose
- port is already allocated — something else owns 80/443 (Apache, Nginx, another Caddy)
- database connection refused — app started before Postgres ready; use healthcheck + depends_on condition
- disk full — docker system df, then prune unused images carefully
- permission denied on docker.sock — user not in docker group, or you need a new login session
Security
Do not publish database ports. Do not run privileged: true. Do not mount /var/run/docker.sock into an app container unless you are writing a container manager on purpose. Keep the engine updated. Scan your own images if you build them. .env on disk is still a secret file — restrict who can ssh to the box.
- No public 5432 / 3306 / 6379
- Pin image digests or at least immutable tags
- chmod 600 .env
- Unattended-upgrades for the host OS still matter
- One Compose project per app keeps blast radius smaller
Tips
- Put compose.yaml in git; keep .env.example without real secrets
- Use profiles for optional workers (docker compose --profile workers up -d)
- Watch docker stats when you size the VPS
- For bind mounts of source code, this is not the production pattern — bake an image
- If the stack grows to several VPS, then look at orchestration — not before
A production-ish Compose setup on one VPS is: official Docker packages, a locked-down .env, a YAML file that does not publish the database, healthchecks, a reverse proxy on 80/443, UFW, image tags you can roll back, and Postgres dumps you have actually restored once. Start from the template in this article, replace the app image, and treat docker compose down -v as a destructive command with the same respect as rm -rf.