
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-levelversionelement 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 thelatesttag is always pulled, even with the defaultmissingpull policy, so a locally builtmyapp:latestsends Compose to a registry first. A tag that moves also leaves no record of which build is running. Here the tag comes fromAPP_TAGin.env, and the:?form makes Compose stop with an error when it is empty. Withbuild: .andimage:together, Compose builds from the folder and names the image afterimage:. - Pinned lines for the other images.
postgres:16-alpine,redis:7-alpineandnginx:1.30-alpineeach 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 plainnginx:alpinefollows 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/dataas here. - One logging block for every service.
x-loggingis a Compose extension field with a YAML anchor, and each service reuses it withlogging: *default-logging. See Log Management below. - A healthcheck before the app starts. The short
depends_onform only waits until the database container runs, not until it accepts connections. Theservice_healthycondition withpg_isreadycomes from Docker's startup order docs. The$$stops Compose from filling in the variables itself, so the container's ownPOSTGRES_USERandPOSTGRES_DBare used. - Two networks. nginx shares only the
webnetwork with the app and reaches it by its service name,app. The database and Redis sit oninternal, 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:
- Docker Compose file reference: version and name, services, build, extensions, fragments, interpolation
- Docker Compose guides: application model, variable interpolation, startup order, networking
- Docker CLI reference: docker compose, compose pull, compose build, compose up, compose logs, image prune, image ls, image rm, container stats
- Docker Engine: install on Ubuntu, install on Debian, post-installation steps, configure logging drivers, JSON file logging driver, local file logging driver, packet filtering and firewalls, build best practices, build context and .dockerignore
- Docker Official Images: tag lists for nginx, postgres and redis, postgres image notes, postgres entrypoint script
- OpenSSH and Ubuntu: sshd_config(5), sshd(8), ssh-copy-id(1), Ubuntu Server: OpenSSH server, ufw(8), adduser(8), usermod(8)
- Certbot: user guide, packages for Ubuntu 24.04 and Debian 12; nginx: proxy_pass, ssl_certificate
- Tools: git rev-parse, sed(1), bash(1), tar(1), find(1)