Skip to content

[ Anolilab — Article / Self-hosting ]

Deploy a Docker app with HTTPS on your own server: Caddy and Docker Compose

Step by step from a fresh Ubuntu server to your app on your own domain, with automatic HTTPS, locked-down SSH and backups.

Article by
Daniel Bannert · Founder · Anolilab

In short

A small server with 1 vCPU and 1 to 2 GB RAM is enough for a small app. Docker Compose starts the app with the web server Caddy in front of it. Caddy gets the HTTPS certificate itself and renews it automatically, so there is no certbot cron job to look after. The key rule: only Caddy publishes ports, the app does not.

This guide takes an app that already runs in Docker to a fresh Ubuntu server (24.04 or 26.04 LTS) and puts it on your own domain with a valid HTTPS certificate.

What you need

  • A server (VPS) with 1 vCPU and 1 to 2 GB RAM for a small app. Any provider works; the VPS provider comparison helps you pick one.
  • A domain whose DNS records you can edit.
  • An app that runs in Docker and listens on a port (3000 here).

Step by step

  1. Point your domain at the server

    Create an A record for app.your-company.com with the server's IPv4 address, and an AAAA record if it has IPv6. Do this first: Caddy can only get a certificate once the domain points at the server. Check with dig +short app.your-company.com.

  2. Lock down the server

    Create your own user, allow SSH login with keys only, limit the firewall to the ports you need.
  3. Install Docker

    From Docker's official package repository, then check docker compose version.
  4. Write the Compose file and Caddyfile

    The app without published ports, Caddy in front of it on ports 80 and 443.
  5. Start it

    docker compose up -d and watch the Caddy log while the certificate is issued.

Lock down the server

Log in as root once, create a normal user and give it your SSH key:

Terminal
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Then turn off password and root login. Ubuntu reads the files in /etc/ssh/sshd_config.d/ first, and for most settings the first value found wins. A file whose name starts with 00- therefore also beats defaults that some provider images put there:

Terminal
printf "PasswordAuthentication no\nPermitRootLogin no\n" | sudo tee /etc/ssh/sshd_config.d/00-hardening.conf
sudo sshd -t
sudo systemctl restart ssh.service

Keep your current session open and test logging in as deploy in a second terminal before you log out. Otherwise one typo locks you out.

Open only the ports you need:

Terminal
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable

Docker bypasses ufw

Any port you publish with ports: in Compose is reachable from the internet, even if ufw blocks it. Docker routes the traffic around the ufw rules (Docker documentation). That is why, below, only Caddy publishes ports and the app does not.

Install Docker

Install Docker from the official package repository as described in Docker's guide for Ubuntu. Docker itself recommends the convenience script from get.docker.com only for testing and development, not for production. Afterwards:

Terminal
sudo usermod -aG docker deploy   # log out and back in afterwards
docker compose version

The docker group effectively gives a user root rights. Only add users you would trust with that.

The Compose file

Create ~/app/compose.yaml:

compose.yaml
services:
    app:
        image: ghcr.io/your-company/your-app:1.0.0 # or build: .
        restart: unless-stopped
        environment:
            - NODE_ENV=production
        # no "ports:" on purpose: only Caddy talks to the app

    caddy:
        image: caddy:2
        restart: unless-stopped
        ports:
            - "80:80"
            - "443:443"
            - "443:443/udp" # HTTP/3
        volumes:
            - ./conf:/etc/caddy
            - caddy_data:/data
            - caddy_config:/config

volumes:
    caddy_data:
    caddy_config:

And ~/app/conf/Caddyfile:

conf/Caddyfile
app.your-company.com {
    encode
    reverse_proxy app:3000
}

app is the Compose service name; Docker's internal DNS resolves it. encode without arguments compresses with zstd and gzip. Caddy gets the conf folder instead of the single file because the Caddy image advises against mounting the Caddyfile directly: some editors replace the file when saving, and the container then doesn't see the change.

Start it

Terminal
cd ~/app
docker compose up -d
docker compose logs -f caddy

Shortly after, the Caddy log reports that the certificate was issued. Open https://app.your-company.com and the app is live.

The caddy_data volume holds your certificates and keys. Don't delete it: Caddy would have to request everything again, and you can run into Let's Encrypt's rate limits.

Deploying updates

Terminal
docker compose pull app && docker compose up -d app
docker image prune -f

To deploy on every push, a GitHub Actions job can SSH into the server and run the same two commands.

Backups

The server is now the only copy of everything your app writes. At minimum:

  • Keep data in a named volume or a bind mount, never only inside the container.
  • Back up the database on a schedule, for Postgres for example with docker compose exec -T db pg_dump -U postgres app > backup.sql.
  • Copy backups off the server, for example with restic to S3-compatible storage. Most providers also sell automatic server backups or snapshots for an extra fee; turn them on.

Troubleshooting

SymptomLikely cause
Caddy log: no such host, or the ACME challenge failsDNS doesn't point at the server yet, or port 80 is blocked in the provider's firewall
502 Bad GatewayThe app listens only on localhost inside the container instead of 0.0.0.0:3000
HTTP works, HTTPS doesn'tPort 443 isn't open in the provider's cloud firewall (which is separate from ufw)

Next steps

If you'd rather click "deploy" than write Compose files, Coolify gives you a Heroku- or Vercel-style dashboard on the same server. And Self-hosting n8n shows how to run an automation platform with the same pattern.

[ Anolilab — Blog ]

More hands-on writing on AI, web development and infrastructure.

Related articles.

[ Anolilab — Contact ]

Tell me what you're building. I'll tell you how I can help.

Let's talk.

Quick response.
If you're ready to create and collaborate, I'd love to hear from you.
Clear next steps.
After the consultation, I'll give you a detailed plan.
Alternatively, you can email me directly at hello@anolilab.com