StackSkipper · Guides
How to deploy Docker containers to your own server, with HTTPS and rollbacks
You need four things: Docker on the server, a Compose file, a reverse proxy that fetches its own certificates, and a way to ship a new image tag. This is the whole setup, from an empty server to your app answering on your domain — and, at the end, what it still leaves to you.
Before you start
- A Linux server you can reach over SSH, with ports 80 and 443 open.
- Your app as an image in a registry — Docker Hub, GitHub's, GitLab's or your own. Building on the server works too, but it puts your build tools on the machine that serves traffic.
- A domain whose DNS you can edit. Point an
Arecord (and anAAAAif the server has IPv6) at the server before the first deploy; the certificate cannot be issued until the name resolves.
1. Install Docker
Install Docker Engine and the Compose plugin from Docker's own package
repository, following the instructions for your distribution at
docs.docker.com/engine/install.
The versions in most distributions' own repositories lag behind and
often lack docker compose. Then log the server in to your
registry once, if the image is private:
docker login ghcr.io
2. Describe the app and the proxy
One directory on the server, say /srv/app, holds everything.
The Compose file runs your image next to Caddy, a web server that
requests and renews Let's Encrypt certificates on its own:
/srv/app/compose.yamlservices:
app:
image: ghcr.io/you/app:${APP_TAG}
restart: unless-stopped
env_file: app.env
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:
caddy_data:
caddy_config:
/srv/app/Caddyfileapp.example.com {
reverse_proxy app:3000
}
Replace 3000 with the port your app listens on inside the
container. The app publishes no port of its own: only Caddy is reachable
from outside, and it reaches the app by its service name. The
caddy_data volume is where the certificates live — keep it,
or Caddy requests new ones on every start and runs into Let's Encrypt's
rate limits.
Secrets go in /srv/app/app.env, readable only by the
deploy user, and never into the Compose file or the image. The
.env file next to it is Compose's own: it holds one line,
the image tag that is live, and Compose fills ${APP_TAG}
from it.
3. Deploy a version
From your machine, or from CI once it works:
ssh [email protected] \
'cd /srv/app && echo APP_TAG=1.4.2 > .env && docker compose pull app && docker compose up -d'
Compose pulls the tag, replaces the app container and
leaves Caddy alone. Give it a minute on the first run while the
certificate is issued, then open https://app.example.com.
4. Roll back
Because every deploy names a tag, rolling back is the same command with the previous one:
ssh [email protected] \
'cd /srv/app && echo APP_TAG=1.4.1 > .env && docker compose up -d'
This is the reason not to deploy latest: a tag that moves
cannot be rolled back to, because by the time you need the old version
the tag already points at the new one. /srv/app/.env says
which tag is live; which one was live before it, nothing on the server
remembers.
5. Watch it
ssh [email protected] 'cd /srv/app && docker compose ps'
ssh [email protected] 'cd /srv/app && docker compose logs -f --tail 100 app'
What this setup still leaves to you
- A short outage on every deploy. Compose stops the old container before the new one answers. Avoiding that means a second instance and a health check the proxy understands.
- Every new domain is an edit to the Caddyfile and a reload, on every server that should serve it.
- The history. What was deployed when, by whom, and which tag to go back to lives in a shell history or in somebody's head.
- The second app, and the second server. Each one is
another directory, another
.envand another set of commands to remember. - The person who set it up. It is understood by exactly one of them, and the afternoon it costs is always theirs.
None of that is a reason not to do it this way. It is a good setup for one app. It is the tenth afternoon that makes people look for something else.
Where StackSkipper comes in
StackSkipper does the same job on a server in your own cloud account — AWS, Hetzner, DigitalOcean or any Terraform provider. Point it at an image, set the environment, add a domain: the certificate is issued and renewed for you. Deploy from a CI webhook on a new tag or with a button, read the logs per service, roll back to the previous tag, and see in one dashboard what runs, how it is doing and what was deployed when.
The server and its invoice stay yours. Under ten minutes from connecting it to a container answering on your domain, given an image in a registry and DNS you can edit. More on the StackSkipper home page.