Docker Compose in Production on a VPS: Complete Setup Guide

Published on | Updated

Run Docker Compose in production on a VPS: SSH and firewall first, Docker from its apt repository, pinned image tags, rotated logs, SSL renewal and backups.

A VPS control panel overview with disk usage, bandwidth, a CPU graph and network speed

Docker Compose on a VPS is the sweet spot between shared hosting and full Kubernetes. You get container isolation and reproducible deployments without the complexity overhead. Here is how to run it in production on Ubuntu 24.04 LTS or Debian 12, with every command checked against the official documentation on 31 October 2026.

Initial VPS Setup

Start with a clean Ubuntu 24.04 LTS or Debian 12 VPS. Docker's install pages list Ubuntu 22.04, 24.04 and 26.04 LTS and Debian 12 and 13 among the supported releases. On a Space-Node VPS you pick the system in the order form, where Ubuntu 24.04 LTS is marked as recommended. How much RAM you need depends on your stack: once it runs, docker stats (see Monitoring) shows what each container uses.

Security First

Run these as root on the new server:

# Update the system and install sudo and ufw
apt update && apt upgrade -y
apt install -y sudo ufw

# Create a non-root user with sudo rights
adduser deploy
usermod -aG sudo deploy

From your own computer, copy your SSH key to the new user and check that the key login works:

ssh-copy-id deploy@your-server-ip
ssh deploy@your-server-ip

ssh-copy-id logs in with the password once and adds your key to ~/.ssh/authorized_keys. Only when the key login works, switch off root logins and passwords; doing it earlier locks you out. Put the settings in a snippet file instead of editing sshd_config with sed. The built-in default for PermitRootLogin is prohibit-password, so a sed that replaces PermitRootLogin yes changes nothing unless that exact line exists, and sshd uses the first value it reads for a setting. Ubuntu's sshd_config includes /etc/ssh/sshd_config.d/*.conf at the very top, and those files are read in name order, so a file starting with 00 comes first:

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
EOF
sudo sshd -t
sudo sshd -T | grep -i -E '^(permitrootlogin|passwordauthentication) '
sudo systemctl restart ssh.service

sshd -t checks the configuration and sshd -T prints the values sshd will actually use; both lines should end in no before you restart. If one still says yes, another file sets it first: sudo grep -rn -E 'PermitRootLogin|PasswordAuthentication' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ finds it. Keep your current session open and test a new login as deploy before you close it.

Then the firewall:

sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable

ufw looks up service names such as ssh in /etc/services, and because the SSH rule exists before ufw enable, port 22 is open once the firewall starts (ufw's manual warns that enabling can still drop open connections, so reconnect if your session freezes). One catch: ufw does not filter ports that Docker publishes. Docker's docs explain that traffic to a published container port is diverted before it reaches ufw's rules, so publish only what must be public (in this setup only nginx) and read our guide to Docker and UFW before you add a ports: line to a database.

Install Docker

Docker's docs say the get.docker.com convenience script "isn't recommended for production environments": it installs the latest release without asking, which can bring an unexpected major version upgrade. Install from Docker's apt repository instead. As deploy on Ubuntu:

# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

On Debian the steps are the same with linux/debian in both URLs and Suites: $(. /etc/os-release && echo "$VERSION_CODENAME").

To run docker without sudo, add the user to the docker group, then log out and back in so the membership applies:

sudo usermod -aG docker deploy

Docker's docs warn that the docker group grants root-level privileges, so only add users you would trust with root.

Production Docker Compose

Directory Structure

/opt/myapp/
├── compose.yaml
├── .env
├── .dockerignore
├── Dockerfile
├── nginx/
│   └── conf.d/
├── data/
│   ├── postgres/
│   └── redis/
└── backups/

compose.yaml is the name Compose looks for first; docker-compose.yml still works for backward compatibility. The app's source code and Dockerfile live in the same folder, because the deploy script below builds each release from a Git checkout.

That folder is also the build context Compose sends to the builder, and it holds the database files, the backups and .env. A .dockerignore file keeps them out of the context, so they are not sent to the builder and a COPY . . line in your Dockerfile cannot copy them into the image:

data/
backups/
.env
.git

The .env File

APP_TAG=first-release
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=use-a-long-random-password

Compose reads .env from the project folder to fill in the ${...} values of the Compose file, and env_file: .env passes the same values into the app container. Keep this file out of Git.

Example Compose File

x-logging: &default-logging
  driver: local
  options:
    max-size: "10m"
    max-file: "3"

services:
  app:
    build: .
    image: "myapp:${APP_TAG:?set APP_TAG in .env}"
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - internal
      - web
    logging: *default-logging

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s
    networks:
      - internal
    logging: *default-logging

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - ./data/redis:/data
    networks:
      - internal
    logging: *default-logging

  nginx:
    image: nginx:1.30-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - app
    networks:
      - web
    logging: *default-logging

networks:
  internal:
    driver: bridge
  web:
    driver: bridge

What matters in this file:

  • No version: line. Docker's Compose reference calls the top-level version element obsolete: it is only informative, Compose always validates against the most recent schema, and you get a warning if the line is there. Delete it from older files.
  • A real tag instead of myapp:latest. Compose's docs say the latest tag is always pulled, even with the default missing pull policy, so a locally built myapp:latest sends Compose to a registry first. A tag that moves also leaves no record of which build is running. Here the tag comes from APP_TAG in .env, and the :? form makes Compose stop with an error when it is empty. With build: . and image: together, Compose builds from the folder and names the image after image:.
  • Pinned lines for the other images. postgres:16-alpine, redis:7-alpine and nginx:1.30-alpine each follow one release line: a pull brings newer releases within that line but never a new major version. On 31 October 2026 the official images library lists nginx 1.30 as the stable line, while plain nginx:alpine follows mainline (1.31). Docker's build docs point out that tags are mutable; pinning a digest is the strict alternative. Before you move PostgreSQL past 17, read the image notes: from 18 on, the data directory moved and the mount point is /var/lib/postgresql, while 17 and older must mount /var/lib/postgresql/data as here.
  • One logging block for every service. x-logging is a Compose extension field with a YAML anchor, and each service reuses it with logging: *default-logging. See Log Management below.
  • A healthcheck before the app starts. The short depends_on form only waits until the database container runs, not until it accepts connections. The service_healthy condition with pg_isready comes from Docker's startup order docs. The $$ stops Compose from filling in the variables itself, so the container's own POSTGRES_USER and POSTGRES_DB are used.
  • Two networks. nginx shares only the web network with the app and reaches it by its service name, app. The database and Redis sit on internal, which nginx is not part of.

Reverse Proxy with SSL

Certbot's standalone mode binds port 80 for the domain check, and port 80 belongs to the nginx container once the stack runs. So get the first certificate before you start the stack, then let renewals stop and start the container. The standalone method needs only the certbot package:

sudo apt install certbot
sudo certbot certonly --standalone -d yourdomain.com

The certificate lands in /etc/letsencrypt/live/yourdomain.com/ as fullchain.pem and privkey.pem. The nginx service mounts /etc/letsencrypt read-only, so point ssl_certificate and ssl_certificate_key in a file under nginx/conf.d/ at those two files, and send requests to the app with proxy_pass http://app:3000; (use the port your app listens on). Then build and start the stack from /opt/myapp with docker compose up -d --build.

For renewals, add two hook scripts. Certbot runs the executables in renewal-hooks/pre before and those in renewal-hooks/post after each renewal attempt:

sudo mkdir -p /etc/letsencrypt/renewal-hooks/pre /etc/letsencrypt/renewal-hooks/post
sudo tee /etc/letsencrypt/renewal-hooks/pre/stop-nginx.sh <<'EOF'
#!/bin/sh
docker compose -f /opt/myapp/compose.yaml stop nginx
EOF
sudo tee /etc/letsencrypt/renewal-hooks/post/start-nginx.sh <<'EOF'
#!/bin/sh
docker compose -f /opt/myapp/compose.yaml start nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/pre/stop-nginx.sh /etc/letsencrypt/renewal-hooks/post/start-nginx.sh
sudo certbot renew --dry-run

The hooks only run when a certificate is due, so nginx is down only while a renewal runs. --dry-run tests a renewal against Let's Encrypt's staging server and runs the pre and post hooks as well. Certbot's docs say most installations schedule certbot renew for you; check for it with systemctl list-timers.

Deployment Workflow

Update the Database, Cache and Proxy Images

cd /opt/myapp
docker compose pull --ignore-buildable
docker compose up -d
docker compose logs -f app

pull fetches the newest patch release on each pinned line, and --ignore-buildable skips the app, which you build yourself. up -d stops and recreates only the containers whose configuration or image changed.

Release a New App Version from Git

#!/bin/bash
set -e
cd /opt/myapp
git pull origin main
TAG=$(git rev-parse --short HEAD)
sed -i "s/^APP_TAG=.*/APP_TAG=$TAG/" .env
docker compose build --no-cache app
docker compose up -d app
docker image prune -f

Each release gets its short commit hash as tag, and .env records which one runs. docker image prune -f removes only dangling images, so earlier tags stay on disk. To roll back, put an older tag from docker image ls myapp into .env and run docker compose up -d app. Old tags take disk space; remove the ones you no longer need with docker image rm myapp:<tag>.

For a Next.js app, our comparison of PM2 and Docker for Next.js adds a Compose service for the official standalone image and a rollback by image tag without a rebuild.

Monitoring

Container Health

# Check all container status
docker compose ps

# View resource usage
docker stats --no-stream

Log Management

Docker's default json-file driver does not rotate logs: its max-size defaults to -1, which means no size limit, and Docker's docs warn that a container with a lot of output can fill the disk. They recommend the local driver instead, which rotates by default (five files of 20 MB per container) and compresses the rotated files. The x-logging block in the Compose file above sets local for every service with three files of 10 MB each, the values from Docker's own example.

To make local the default for every container on the server, including ones you start outside this project, put this in /etc/docker/daemon.json (if the file already exists, add the key to it instead of overwriting it):

{
  "log-driver": "local"
}

Then restart Docker and check the result:

sudo systemctl restart docker
docker info --format '{{.LoggingDriver}}'

Only containers created after the change use the new default, so recreate older containers to move them over; for a Compose project that is docker compose up -d --force-recreate. The services in this stack already set local themselves.

Backups

Back up your data directory regularly:

#!/bin/bash
set -e
cd /opt/myapp
BACKUP_DIR="/opt/myapp/backups"
DATE=$(date +%Y%m%d_%H%M%S)

# Stop the database for a consistent copy, and start it again however the script ends
docker compose stop db
trap 'docker compose start db' EXIT

# Archive the data directory
tar czf "${BACKUP_DIR}/data_${DATE}.tar.gz" ./data/

# Remove archives older than 7 days
find "${BACKUP_DIR}" -name "data_*.tar.gz" -mtime +7 -delete

The cd lets the script run from cron, set -e stops it at the first error, and the trap ... EXIT line starts the database again even when tar fails, so a failed night never leaves your app without its database. The database is down while tar runs.

Run the script as root, for example from root's crontab (sudo crontab -e). The postgres image's entrypoint makes the container's postgres user the owner of the data folder and sets it to mode 700, so your deploy user cannot read it.

This script copies the ./data folder only. For named volumes, database dumps, a restore on a new VPS and restore tests, see our Docker volume backup and restore guide. The archives also sit on the same disk as the data, so copy them off the server, for example with restic over SSH. Backups are not included in the price of a Space-Node VPS plan; you can arrange them through a support ticket.

Performance Tips

  • Use Alpine-based images where they work for you: Docker recommends the Alpine base image because it is small (under 6 MB)
  • Add health checks, like the one on the database above, so dependent services wait until a service is ready
  • Set resource limits (mem_limit, cpus) to prevent one container from starving others
  • Use Docker's built-in restart policies instead of external process managers
  • Keep images small: multi-stage builds leave build dependencies out of the final image

Docker Compose on a VPS is production-ready for most applications. With pinned tags, rotated logs, certificate renewals that run on their own and backups that leave the server, you get a deployment you can maintain and roll back.

Sources

All opened on 31 October 2026: