All articles

2 min read

Deploying Astro with Docker and Caddy

A static build, two containers and an automatic TLS certificate — without depending on a platform.

  • Astro
  • Docker
  • Caddy
  • CI/CD

The problem

An Astro site produces static files. You could copy them to a VPS with scp, but every update would become a manual operation, with no traceability and no rollback. The goal is the opposite: an immutable image per commit, a reproducible deployment and a TLS certificate that renews itself.

A two-stage build

The first stage installs dependencies and builds the site. The second contains only nginx and the generated files: the final image carries neither Node nor node_modules.

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

The important part is the selective COPY: package*.json first, the rest afterwards. As long as dependencies do not change, Docker reuses the cache and the build stays fast.

Caddy instead of an exposed nginx

Caddy terminates TLS and routes traffic to internal containers. No application port is published on the host: only Caddy listens on 80 and 443.

hosayni.com {
	encode zstd gzip
	root * /srv/web
	try_files {path} {path}/ /404.html
	file_server

	handle /api/* {
		reverse_proxy api:8000
	}

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
	}
}

Caddy obtains and renews the certificate without intervention. The try_files directive reproduces classic static hosting behaviour: /en/projects/ serves index.html, and an unknown URL falls back to the 404 page.

What changes

  • Deployment becomes docker compose pull && docker compose up -d.
  • A rollback means restarting the previous SHA tag.
  • Security headers live in the same place as TLS.
  • The site stays online even if the API is temporarily unavailable.

Going further

The missing piece is automation: build images in GitHub Actions, publish them to ghcr.io, then trigger the deployment over SSH with a healthcheck before validating the new version. That is exactly the pipeline described on this site’s Infrastructure page.