Back to blog
August 19, 2026Guides

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.

How to Deploy Applications with Docker Compose on a VPS

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.

bash
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 version

Step 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.

bash
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/app

Step 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.

bash
# /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.

bash
# /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=100

Step 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.

bash
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 docker

Step 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.

bash
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 volumes

Back 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.

bash
# 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 app

Troubleshooting

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.