Self-hosting with Docker Compose, Caddy and a GitHub self-hosted runner
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
mainunless 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_oninstead ofsleepcommands. - 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
Rate limiting and login lockout in NestJS with Redis
Two layers of protection for an API: per-IP request limits with @nestjs/throttler stored in Redis, and a temporary per-email lock after repeated failed logins that never reveals which accounts exist.
Read MoreAccepting VietQR bank transfers with webhook confirmation
The VietQR payment flow we designed for Dailerc Tools Store: an order code in the transfer content, an authenticated webhook, exact amount checks, duplicate-safe processing and a manual fallback.
Read MoreISR and on-demand revalidation in Next.js 15
How dailerc.com serves static pages fast and still updates the moment an admin edits content: tagged fetches, revalidateTag after every change, and a guarded revalidate route for deploys.
Read More