← Blog

Self-hosting with Docker Compose, Caddy and a GitHub self-hosted runner

Backend and DevOps

How dailerc.com runs on its own server: one origin behind Caddy, services in Docker Compose with health checks, and every commit on main deployed by a self-hosted runner with backups and rollback.

Cloud platforms are convenient, but for a website and an internal system a single server is often enough, has predictable costs and keeps the data in your own hands. This post describes how dailerc.com is deployed: Docker Compose, Caddy and a GitHub Actions runner that lives on the server itself.

One origin, with Caddy in front

The public site, the admin, the API and the WebSockets all share one domain. That keeps SameSite=Strict cookies simple and avoids complicated CORS setups. Caddy obtains and renews the Let's Encrypt certificate itself, then routes by path:

@api path /api/v1/* /socket.io/*
handle @api {
  reverse_proxy api:4000
}
handle_path /media/* {
  reverse_proxy minio:9000
}
handle {
  reverse_proxy web:3000
}

Images and files go through /media/* to MinIO, and only GET and HEAD are allowed; any other method gets a 405, and the Authorization header is dropped before proxying. Because every upload has a unique key, successful responses are cached for a year as immutable. Caddy also adds HSTS and nosniff, compresses with zstd or gzip, and answers 404 for the internal Next.js revalidate route.

Docker Compose with health checks and start order

docker-compose.prod.yml holds Caddy, Next.js, the NestJS API, PostgreSQL 16, Redis 7 (with append-only persistence so a restart loses nothing), MinIO and a one-shot migrate service. The start order is enforced with conditions:

api:
  depends_on:
    migrate: { condition: service_completed_successfully }
    postgres: { condition: service_healthy }
    redis: { condition: service_healthy }

The API starts only after migrations have finished, and the web app starts only when the API reports healthy on /api/v1/health. The services share an internal network; only Caddy publishes ports. Logs use the json-file driver capped at 10 MB per file and 5 files, so the disk never fills up.

GitHub Actions on a self-hosted runner

The deploy workflow runs on a runner installed on the server, so no outside service needs SSH access:

on:
  push: { branches: [main] }
  workflow_dispatch:
concurrency: { group: deploy-production, cancel-in-progress: false }
jobs:
  deploy:
    runs-on: [self-hosted, dailerc-deploy]
    steps:
      - run: ~/dailerc-tech-deploy/scripts/deploy.sh "$REF"

concurrency makes sure only one deploy runs at a time and that a running deploy is never cancelled halfway. The workflow runs only on a push to main or by hand, never on code from a pull request. That matters with a self-hosted runner, because the runner has access to a real server.

What the deploy script does

  • Takes a lock so two deploys never overlap, and takes over a lock whose process has died.
  • Resets the deploy checkout to the exact commit, and refuses a commit that is not on main unless forced.
  • Checks the environment, then backs up PostgreSQL (keeping the last 7 dumps) and the MinIO data before any migration runs.
  • Builds images tagged with the commit hash, and skips the build when those images already exist.
  • Runs docker compose up, waits for each service to become healthy, then reloads Caddy.
  • Smoke-tests through Caddy with curl --resolve, so the certificate is checked without depending on DNS.
  • Refreshes the Next.js cache from inside the web container, then prunes old images while keeping the last few releases.

When any step fails, the script stops at once and prints the status and logs of the services involved.

Rollback is just deploying an older commit

Because images are tagged by commit and the last few are kept, a rollback means running the workflow by hand with an older commit hash. Nothing is rebuilt, so it is quick. One limit to keep in mind: migrations only go forward. If the new release changed the schema in a way the old code cannot read, the dump taken before the migration is the way back.

Lessons

  • One origin behind one reverse proxy makes cookies, CORS and HTTPS much simpler.
  • Use health checks and conditional depends_on instead of sleep commands.
  • Always back up before migrating, and smoke-test through the same path your users take.
  • A self-hosted runner should only run code that has reached the main branch, never code from pull requests.

Related Posts