Wolkra

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, not ports, 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_on with 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:

  1. Publish only the reverse proxy (as above).
  2. Bind anything else to localhost: "127.0.0.1:5432:5432".
  3. 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:

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.