Skip to main content

Deploy a Docker Compose App to a VPS Behind Caddy with HTTPS

From a fresh Ubuntu VPS to a running Docker Compose stack behind Caddy with HTTPS: install, firewall traps, compose config, updates and fixes.

To deploy a Docker Compose app to a VPS, install Docker Engine and the Compose plugin from Docker's apt repository. Copy your project to the server, publish the app port on 127.0.0.1 only and start it with docker compose up -d --wait. Then put a reverse proxy such as Caddy in front to handle HTTPS. We run our own servers this way, usually from one DockLab window with the terminal, SFTP and editor side by side.

Quick answer

  • Point a DNS A record at the VPS, then allow only SSH, 80 and 443 in ufw.
  • Install docker-ce and docker-compose-plugin from Docker's official apt repository, not the Ubuntu docker.io package.
  • In compose.yaml, publish the app as "127.0.0.1:3000:3000", add restart: unless-stopped, and give the database no published port.
  • Install Caddy on the host with a two-line Caddyfile: app.example.com { reverse_proxy 127.0.0.1:3000 }.
  • Deploy updates with git pull, docker compose build web and docker compose up -d --no-deps --wait web.

What you need before you start

You need a VPS running Ubuntu 24.04 LTS or 26.04 LTS (both are on Docker's supported list, along with 22.04), SSH key access and a domain you control. Create the DNS A record (and AAAA if the server has IPv6) before you do anything else. Caddy can only get a certificate once the name resolves to the server, and DNS can take a while to propagate.

You also need to choose where TLS terminates. Here are the three setups that people actually use:

SetupGood atWeak at
Caddy on the host (this guide)Automatic HTTPS with a tiny config, survives docker compose downOne more service to update with apt
Nginx + certbot on the hostFamiliar, lots of existing configs, fine-grained tuningMore files to manage, separate renewal setup
Proxy as a container in the compose fileEverything in one project, label-based routing with some proxiesProxy goes down with the stack, ports 80/443 published by Docker bypass ufw

We use Caddy on the host for single-app servers. Its config is short enough to review at a glance, and it keeps working while you rebuild the stack. If you already run Nginx, that's fine too. The compose side of this guide stays the same.

Prepare the VPS and the firewall

  1. Log in and update

    ssh [email protected]
    sudo apt update && sudo apt upgrade -y
    

    If you haven't set up a host alias yet, our SSH config file examples show how to make this ssh app1.

  2. Open only what the proxy needs

    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable
    sudo ufw status verbose
    

One trap catches most people here: ufw does not protect ports that Docker publishes. Docker's packet filtering documentation says it directly: traffic to a published container port "gets diverted before it goes through the ufw firewall settings." So if your compose file says ports: - "5432:5432", Postgres is on the public internet no matter what ufw status says.

The fix isn't another firewall rule. Publish ports on the loopback address so only the host can reach them. We do that in the compose file below.

Install Docker Engine and the Compose plugin

Use Docker's own repository. The commands below come straight from the official Ubuntu install page, which now uses a deb822 .sources file:

sudo apt update
sudo apt install -y 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

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 -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo docker run --rm hello-world
docker compose version

Before you start any containers, turn on log rotation. The default json-file driver keeps logs forever. According to the json-file driver docs, max-size defaults to unlimited, and the setting only applies to containers created after the change. Write /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
sudo systemctl restart docker

If you want to run docker without sudo, add your user to the group with sudo usermod -aG docker $USER, then log out and back in. Keep in mind that this group is effectively root on the host. Only add users you would trust with sudo anyway.

Put the project on the server

We keep each app in /srv/<app> and deploy from git:

sudo mkdir -p /srv/myapp
sudo chown deploy:deploy /srv/myapp
git clone [email protected]:yourorg/myapp.git /srv/myapp
cd /srv/myapp
cp .env.example .env
chmod 600 .env

The .env file does two jobs. Compose reads it for ${VAR} interpolation inside compose.yaml, and env_file: injects variables into a container. Docker's environment variable docs explain the difference. When a value shows up empty inside the container, this mix-up is usually why.

Edit .env however you like. Over SSH that means nano .env. In DockLab we open it from the SFTP panel in the Monaco editor next to the terminal, and the Env Manager plugin flags variables that are missing compared to .env.example. For the trade-offs between editing methods, see Edit Files on a Remote Server.

Write a compose file that is safe on a public server

Here is a typical web app with Postgres. The important lines are explained after it.

services:
  web:
    build: .
    image: myapp-web:latest
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  db-data:
  • 127.0.0.1:3000:3000: without a host address, Docker publishes to every interface. With the loopback address, only the host (and Caddy on it) can connect. One caveat from the same page: before Engine 28.0.0, hosts on the same L2 segment could still reach localhost-published ports, so check docker version on older servers.
  • No ports on db: web reaches it as db:5432 on the compose network. Nothing outside needs it.
  • restart: unless-stopped: brings containers back after a reboot or a crash. Docker's production guidance recommends a restart policy for exactly this reason.
  • condition: service_healthy: web waits until Postgres accepts connections, not just until the container exists. This is the pattern from the startup order docs. The $$ escapes the variable so the container shell expands it, not Compose.
  • Named volume: the database survives docker compose down. Only down -v deletes it, so keep that flag out of your shell history.

One more detail inside the container: the app must listen on 0.0.0.0, not 127.0.0.1. If it binds to its own loopback, the port mapping goes nowhere and Caddy returns 502.

Start it:

docker compose up -d --wait
docker compose ps
curl -I http://127.0.0.1:3000

--wait (see the compose up reference) returns only when the services are running or healthy, which makes it safe to use in scripts.

Add Caddy for HTTPS

Install Caddy from its official repository, using the commands from the Caddy install page:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Replace the contents of /etc/caddy/Caddyfile:

app.example.com {
	reverse_proxy 127.0.0.1:3000
}
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://app.example.com

That is the whole proxy config. The reverse proxy quick start lists what automatic HTTPS needs: DNS pointing at the machine, and ports 80 and 443 open to Caddy. If either is missing, certificate issuance fails. journalctl -u caddy -f shows the reason.

Deploying updates without surprises

Each later deploy is a few commands. Put them in deploy.sh in the repo:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/myapp
git pull --ff-only
docker compose build web
docker compose up -d --no-deps --wait web
docker image prune -f

--no-deps recreates only web and leaves the database alone. This is the same pattern as in Docker's production guide. Expect a few seconds of downtime while the container is replaced. If that's not acceptable, you need two instances behind the proxy, which is beyond this guide.

For rollbacks, tag images with the commit (image: myapp-web:${APP_VERSION:-latest}) and keep the last few. To go back, set APP_VERSION to the previous tag and run up again, with no rebuild.

Failure modes we see most

SymptomLikely causeFix
Database reachable from the internet despite ufwports: "5432:5432" publishes on all interfacesRemove the mapping or bind to 127.0.0.1
Caddy returns 502Container restarting, wrong host port, or app bound to 127.0.0.1 inside the containerdocker compose ps, docker compose logs web, listen on 0.0.0.0
Certificate never issuedDNS not pointing here yet, or port 80 blockeddig +short app.example.com, ufw status, journalctl -u caddy
web exits on boot with connection refusedStarted before the database was readydepends_on with condition: service_healthy
Disk fills up over weeksUnrotated container logs, old imagesdaemon.json log options, then recreate containers; docker image prune
Variable empty inside the containerUsed ${VAR} interpolation but never passed it inAdd env_file: .env or list it under environment:

For a 502 that persists after these checks, follow our Nginx 502 diagnostic order. The upstream checks there work the same way behind Caddy.

DockLab is a good fit when you do this on several servers and want the terminal, files, editor and compose status in one saved workspace. It doesn't replace a CI pipeline, and it doesn't keep processes alive: the containers do that through their restart policy. If you'd like to try it on your next deploy, download DockLab.

Frequently Asked Questions

Do I need Nginx if I use Docker Compose on a VPS?

No. You need a reverse proxy for TLS and for routing domains, but it doesn't have to be Nginx. Caddy handles certificates automatically with a two-line config. Nginx with certbot works just as well if you already know it.

Why does ufw not block my Docker ports?

Docker routes published ports in the nat table, before ufw's rules are evaluated. Docker's documentation calls the two incompatible for this reason. Bind published ports to 127.0.0.1, and don't publish ports for services that only other containers use.

Should I use docker.io from Ubuntu or Docker's repository?

We use Docker's official repository. It ships current Engine releases and includes the docker compose v2 plugin. The Ubuntu package works, but it often lags behind, and you may end up with a different Compose setup than the documentation assumes.

How much downtime does docker compose up cause during a deploy?

With a single instance, there is a short gap while Compose stops the old container and starts the new one. That's usually a few seconds, plus your app's startup time. Building the image before running up, as in the script above, keeps that gap small. True zero-downtime needs two instances and a proxy that health-checks them.

Where should secrets live on the server?

Put them in a .env file next to compose.yaml, with chmod 600 and an entry in .gitignore, and pass it with env_file. Don't bake secrets into images, because anyone who can pull the image can read them. If several people deploy, generate the file from your secret store during deployment instead of editing it by hand.

Try it on your own servers

SSH, SFTP, a remote editor, a browser and AI in one desktop workspace.

DockLab runs on macOS, Windows and Linux. Start free and keep your whole server workflow in one window.

About the author

Horia Stan · Co-founder, Legacies

Horia builds DockLab and spends most days on remote servers, deploy pipelines and the tooling around them.