Deploy an app with Docker Compose on a VPS
Updated
To deploy with Docker Compose on a VPS, install Docker Engine and the Compose plugin, put a compose.yaml and an .env file in a project directory, run docker compose up -d, and put a reverse proxy such as Caddy in front for HTTPS. Updates are then docker compose pull && docker compose up -d.
This guide builds a small but realistic stack: a web app, a Postgres database and Caddy for automatic HTTPS, on Ubuntu 24.04 or Debian 12.
1. Prepare the server
Start with a server that has had the first steps done: updates, a sudo user, SSH keys and a firewall. For this stack, 2 GB of memory is a sensible minimum.
Point a DNS record at the server before you start, so HTTPS works on the first try:
app.example.com. A 203.0.113.10 ; example address
app.example.com. AAAA 2001:db8::10 ; example address
2. Install Docker Engine and Compose
Use Docker's own repository; the distribution packages are often older.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc
sudo 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/$ID $VERSION_CODENAME stable" | \
sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
$ID is ubuntu or debian, so the same lines work on both. Check:
sudo docker run --rm hello-world
docker compose version
Let your user run Docker without sudo (log out and in afterwards):
sudo usermod -aG docker $USER
Membership of the docker group is effectively root access. Only add trusted users.
Shortcut: the Docker one-click app on Wolkra installs Docker CE with Compose for you.
3. Lay out the project
mkdir -p ~/apps/myapp && cd ~/apps/myapp
A clear layout makes the server easy to understand months later:
~/apps/myapp/
├── compose.yaml
├── .env # secrets, not in git
└── Caddyfile
4. Write compose.yaml
services:
app:
image: ghcr.io/example/myapp:1.4.2
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp
depends_on:
db:
condition: service_healthy
expose:
- "3000"
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_DB: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
db-data:
caddy-data:
caddy-config:
Points worth noting:
- Pin image versions (
1.4.2,16) so a restart never pulls a surprise major upgrade. expose, notports, for the app and database. They are reachable only inside the Compose network. Only Caddy publishes ports.- Named volumes keep data when containers are recreated.
depends_onwith a health check waits for Postgres to accept connections.
Replace ghcr.io/example/myapp with your own image and port.
5. Store secrets in .env
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
APP_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 .env
Compose reads .env from the project directory for ${...} substitution, and env_file passes it into the app. Add .env to .gitignore if the directory is a git checkout.
6. Add HTTPS with Caddy
Create Caddyfile:
app.example.com {
encode zstd gzip
reverse_proxy app:3000
}
Caddy gets and renews a certificate automatically as soon as DNS points to the server and ports 80 and 443 are open. For more options see the Caddy reverse proxy guide.
7. Start the stack
docker compose up -d
docker compose ps
docker compose logs -f caddy
When Caddy logs certificate obtained successfully, open https://app.example.com.
8. Mind the firewall
Docker inserts iptables rules that run before UFW. A port published with ports: is reachable from the internet even if UFW says it is blocked. Three ways to stay safe:
- Publish only the reverse proxy (as above).
- Bind anything else to localhost:
"127.0.0.1:5432:5432". - Filter in front of the server with a cloud firewall, which Docker cannot bypass. Allow 80 and 443 from anywhere and 22 only from your address.
9. Update without drama
Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker image prune -f
Compose recreates only the services whose image or config changed. To roll back, set the old tag and run up -d again. Take a snapshot before big upgrades, such as a new Postgres major version, which needs a dump and restore.
10. Back up the data
Volumes live under /var/lib/docker/volumes. For a database, a logical dump is safer than copying files:
docker compose exec -T db pg_dump -U myapp myapp | gzip > ~/backups/myapp-$(date +%F).sql.gz
Run that from cron each night and copy the files off the server. Server-level backups help too: on Wolkra, daily backups cost 20% of the server price and keep 7 restore points. The backup strategy guide puts it together.
11. Logs and limits
Default Docker logs grow without limit. Cap them globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Then sudo systemctl restart docker. You can also limit memory per service with mem_limit: 512m so one container cannot starve the others.
12. Start on boot
restart: unless-stopped plus the enabled Docker service means the stack comes back after a reboot. Test it once:
sudo reboot
# log in again
docker compose -f ~/apps/myapp/compose.yaml ps
13. Deploy from CI
Once the stack runs, deploys can be automatic. A CI job builds and pushes an image with a new tag, then connects to the server over SSH and runs:
cd ~/apps/myapp
sed -i 's|myapp:.*|myapp:1.4.3|' compose.yaml
docker compose pull app
docker compose up -d app
Give the CI job its own SSH key, limited to this server, as described in the SSH keys guide.
When to grow beyond one server
Compose on one server goes a long way. When you need more, the usual next steps are:
- move the database to its own server on a private network;
- run two app servers behind a load balancer;
- keep data on a block storage volume that you can move between servers.
For managing many apps through a web UI, the Coolify one-click app builds on the same Docker foundation. More ideas are on Docker hosting.
Frequently asked questions
Is Docker Compose good enough for production?
For one server, yes. Many small and medium apps run on Compose for years. Move to an orchestrator only when you need several hosts, automatic failover or rolling updates across machines.
How big a VPS do I need for Docker Compose?
Add up what each container uses. A web app with Postgres and Redis is comfortable with 2 to 4 GB of memory; leave room for image builds if you build on the server.
Why does UFW not block my container ports?
Docker adds its own iptables rules that run before UFW. Publish ports on 127.0.0.1 and put a reverse proxy in front, or filter with a cloud firewall.
Should I build images on the server?
It works for small projects. For repeatable deploys, build in CI, push to a registry and only pull on the server.
Can I skip installing Docker myself?
Yes. On Wolkra, choose the Docker CE one-click app when you create a server; it comes with Docker and the Compose plugin installed.