stackskipper Connect your server

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 A record (and an AAAA if 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 .env and 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.

How many hours went into deployments this month?

Connect the server you already have. The first project is free.

Free for one project, no card. One click to leave.