This stack runs the Astro blog behind a Caddy reverse proxy with the Coraza WAF (loaded with the OWASP Core Rule Set) and the Google Cloud DNS plugin for ACME DNS-01 certificates.
.
├── Containerfile # App image (node:24-trixie-slim → astro preview)
├── caddy/
│ ├── Containerfile # xcaddy build: Coraza + googleclouddns
│ ├── Caddyfile # Reverse proxy + WAF + TLS config
│ └── coraza.conf # Local Coraza overrides
├── compose.yaml # podman compose / docker compose entrypoint
└── quadlet/ # Systemd Quadlet units (production)
├── dev-blog.network
├── dev-blog-app.container
├── dev-blog-caddy.container
├── caddy-data.volume
└── caddy-config.volume
compose.override.yaml is auto-loaded by podman compose, so the default
invocation is dev mode (high ports, rootless-friendly, self-signed HTTPS):
podman compose up --build
# → http://localhost:8080
# → https://localhost:8443 (self-signed via Caddy `tls internal`)The dev override bind-mounts caddy/Caddyfile.dev into the Caddy container
which uses tls internal instead of ACME, so HTTPS works locally without
needing a real domain or GCP credentials. Your browser will warn about the
self-signed cert; trust it for localhost if you want a clean page.
For production (privileged ports 80/443, ACME via Cloud DNS) skip the
override file with an explicit -f:
# Provide a Google Cloud service-account key with Cloud DNS admin on your zone:
mkdir -p secrets && cp /path/to/key.json secrets/gcp-dns.json
# Override the public hostname / project:
export SITE_ADDRESS=https://blog.example.com
export ACME_EMAIL=you@example.com
export GCP_PROJECT=my-gcp-project
podman compose -f compose.yaml up -d --buildEither mode also accepts ad-hoc port overrides via HTTP_PORT/HTTPS_PORT
environment variables.
Copy the unit files into a Quadlet search path and reload systemd:
# rootful
sudo cp quadlet/* /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start dev-blog-caddy.service # pulls in app + network
# rootless
mkdir -p ~/.config/containers/systemd
cp quadlet/* ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start dev-blog-caddy.serviceBuild the images first so the Quadlet units can find them locally:
podman build -t localhost/dev-blog-app:latest .
podman build -t localhost/dev-blog-caddy:latest ./caddy
podman secret create gcp-dns-sa /path/to/key.json- The app container is not published to the host – Caddy reaches it on the
internal
dev-blognetwork atapp:4321. - The OWASP CRS (v4.7.0 by default) is baked into the Caddy image; bump
CRS_VERSIONincaddy/Containerfileto upgrade. - Coraza's
load_owasp_crsdirective in the Caddyfile enables the CRS rules that cover the OWASP Top 10 (injection, XSS, RCE, LFI/RFI, scanner detection, protocol violations, session fixation, etc.).