diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d350cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.vscode +node_modules +dist +.astro +coverage +playwright-report +test-results +e2e +**/*.log +.env +.env.* +README.md diff --git a/CONTAINER.md b/CONTAINER.md new file mode 100644 index 0000000..b1992bd --- /dev/null +++ b/CONTAINER.md @@ -0,0 +1,92 @@ +# Container deployment + +This stack runs the Astro blog behind a Caddy reverse proxy with the +[Coraza](https://coraza.io/) WAF (loaded with the OWASP Core Rule Set) and the +Google Cloud DNS plugin for ACME DNS-01 certificates. + +## Layout + +``` +. +├── 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 +``` + +## Quick start (compose) + +`compose.override.yaml` is auto-loaded by `podman compose`, so the default +invocation is **dev mode** (high ports, rootless-friendly, self-signed HTTPS): + +```sh +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`: + +```sh +# 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 --build +``` + +Either mode also accepts ad-hoc port overrides via `HTTP_PORT`/`HTTPS_PORT` +environment variables. + +## Production (Quadlet) + +Copy the unit files into a Quadlet search path and reload systemd: + +```sh +# 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.service +``` + +Build the images first so the Quadlet units can find them locally: + +```sh +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 +``` + +## Notes + +- The app container is *not* published to the host – Caddy reaches it on the + internal `dev-blog` network at `app:4321`. +- The OWASP CRS (v4.7.0 by default) is baked into the Caddy image; bump + `CRS_VERSION` in `caddy/Containerfile` to upgrade. +- Coraza's `load_owasp_crs` directive 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.). diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..90f4296 --- /dev/null +++ b/Containerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.7 + +# ---------- Build stage ---------- +FROM node:24-trixie-slim AS build + +ENV PNPM_HOME=/pnpm \ + PATH=/pnpm:$PATH \ + CI=1 + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + + +# ---------- Runtime stage ---------- +FROM node:24-trixie-slim AS runtime + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4321 \ + ASTRO_TELEMETRY_DISABLED=1 + +RUN groupadd --system --gid 1001 astro \ + && useradd --system --uid 1001 --gid astro --shell /usr/sbin/nologin astro + +WORKDIR /app + +# Only what's needed to run `astro preview` +COPY --from=build --chown=astro:astro /app/package.json ./package.json +COPY --from=build --chown=astro:astro /app/node_modules ./node_modules +COPY --from=build --chown=astro:astro /app/dist ./dist +COPY --from=build --chown=astro:astro /app/astro.config.mjs ./astro.config.mjs + +USER astro + +EXPOSE 4321 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+ (process.env.PORT||4321) +'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +# Invoke astro directly via node to avoid corepack/pnpm shims at runtime +# (the rootfs is read-only and corepack would try to write a cache dir). +CMD ["node", "./node_modules/astro/bin/astro.mjs", "preview", "--host", "0.0.0.0", "--port", "4321"] diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..4ef8d8c --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,70 @@ +# ---------------------------------------------------------------------------- +# Caddyfile – fronts the Astro app, terminates TLS, and runs the Coraza WAF +# with the OWASP Core Rule Set loaded. +# ---------------------------------------------------------------------------- + +{ + # Make sure the WAF runs before the reverse-proxy handler. + order coraza_waf before reverse_proxy + + # Email used for Let's Encrypt account registration. + email {$ACME_EMAIL:admin@example.com} +} + +# ---------------------------------------------------------------------------- +# Public site +# ---------------------------------------------------------------------------- +{$SITE_ADDRESS:http://:80} { + encode zstd gzip + + # ------------------------------------------------------------------ + # WAF – Coraza + OWASP Core Rule Set (Top 10 protections) + # ------------------------------------------------------------------ + coraza_waf { + load_owasp_crs + + directives ` + Include @coraza.conf-recommended + Include @crs-setup.conf.example + Include @owasp_crs/*.conf + SecRuleEngine On + SecRequestBodyAccess On + SecResponseBodyAccess Off + SecDefaultAction "phase:1,log,auditlog,deny,status:403" + SecDefaultAction "phase:2,log,auditlog,deny,status:403" + ` + } + + # ------------------------------------------------------------------ + # TLS via ACME DNS-01 with the Google Cloud DNS provider. + # Falls back to no-TLS automatically when SITE_ADDRESS is http://... + # ------------------------------------------------------------------ + tls { + dns googleclouddns { + gcp_project {$GCP_PROJECT} + } + resolvers 8.8.8.8 1.1.1.1 + } + + # ------------------------------------------------------------------ + # Reverse-proxy to the Astro container on the internal network + # ------------------------------------------------------------------ + reverse_proxy app:4321 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Standard hardening headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } +} diff --git a/caddy/Caddyfile.dev b/caddy/Caddyfile.dev new file mode 100644 index 0000000..5c19097 --- /dev/null +++ b/caddy/Caddyfile.dev @@ -0,0 +1,58 @@ +# Development Caddyfile – local HTTPS via `tls internal` (self-signed, no ACME). +# Mounted into the Caddy container by compose.override.yaml, replacing the +# production Caddyfile. Same WAF + reverse-proxy behaviour, just a simpler TLS +# story so https://localhost:8443 works without any cloud credentials. + +{ + order coraza_waf before reverse_proxy + # Disable HTTP→HTTPS auto-redirects in dev (we publish on different ports). + auto_https disable_redirects +} + +# Shared handler chain for both the HTTP and HTTPS listeners. +(site) { + encode zstd gzip + + coraza_waf { + load_owasp_crs + + directives ` + Include @coraza.conf-recommended + Include @crs-setup.conf.example + Include @owasp_crs/*.conf + SecRuleEngine On + SecRequestBodyAccess On + SecResponseBodyAccess Off + SecDefaultAction "phase:1,log,auditlog,deny,status:403" + SecDefaultAction "phase:2,log,auditlog,deny,status:403" + ` + } + + reverse_proxy app:4321 { + header_up X-Real-IP {remote_host} + } + + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } +} + +# Plain-HTTP listener – the `http://` scheme is needed so Caddy binds :80 +# instead of upgrading these names to HTTPS-only. +http://localhost, http://127.0.0.1, http://[::1] { + import site +} + +# HTTPS listener with a self-signed cert from Caddy's local CA. +https://localhost, https://127.0.0.1, https://[::1] { + tls internal + import site +} diff --git a/caddy/Containerfile b/caddy/Containerfile new file mode 100644 index 0000000..969f164 --- /dev/null +++ b/caddy/Containerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.7 + +# Build a custom Caddy with the Coraza WAF plugin and the +# Google Cloud DNS provider for ACME DNS-01 challenges. + +ARG CADDY_VERSION=2.11.2 + +FROM caddy:${CADDY_VERSION}-builder AS builder + +# coraza-caddy/v2 requires Go >= 1.25, but the caddy:builder image still ships +# Go 1.24. GOTOOLCHAIN=auto lets the Go toolchain transparently download the +# version requested by each module's go.mod. +ENV GOTOOLCHAIN=auto + +RUN xcaddy build \ + --with github.com/corazawaf/coraza-caddy/v2 \ + --with github.com/caddy-dns/googleclouddns + + +FROM caddy:${CADDY_VERSION} + +# OWASP Core Rule Set (CRS) – pinned, baked into the image so it's available offline. +ARG CRS_VERSION=4.7.0 +RUN set -eux; \ + apk add --no-cache --virtual .fetch curl tar; \ + mkdir -p /etc/caddy/coraza /etc/caddy/coraza/owasp_crs; \ + curl -fsSL "https://github.com/coreruleset/coreruleset/archive/refs/tags/v${CRS_VERSION}.tar.gz" \ + -o /tmp/crs.tgz; \ + tar -xzf /tmp/crs.tgz -C /tmp; \ + cp -r "/tmp/coreruleset-${CRS_VERSION}/rules" /etc/caddy/coraza/owasp_crs/rules; \ + cp "/tmp/coreruleset-${CRS_VERSION}/crs-setup.conf.example" /etc/caddy/coraza/crs-setup.conf; \ + curl -fsSL https://raw.githubusercontent.com/corazawaf/coraza/main/coraza.conf-recommended \ + -o /etc/caddy/coraza/coraza.conf; \ + rm -rf /tmp/crs.tgz "/tmp/coreruleset-${CRS_VERSION}"; \ + apk del .fetch + +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY coraza.conf /etc/caddy/coraza/local.conf diff --git a/caddy/coraza.conf b/caddy/coraza.conf new file mode 100644 index 0000000..1c11704 --- /dev/null +++ b/caddy/coraza.conf @@ -0,0 +1,23 @@ +# --------------------------------------------------------------------------- +# Local Coraza overrides. +# +# The recommended base config and the OWASP CRS are loaded from the Caddyfile +# via the `load_owasp_crs` directive (which exposes them under the +# @coraza.conf-recommended, @crs-setup.conf.example, and @owasp_crs/* aliases). +# +# Add per-site exceptions / tuning below. +# --------------------------------------------------------------------------- + +# Engine in blocking mode. +SecRuleEngine On + +# Reasonable request-body limits for a static blog. +SecRequestBodyLimit 13107200 +SecRequestBodyNoFilesLimit 131072 +SecRequestBodyLimitAction Reject + +# Drop very noisy false-positives on static asset paths. +SecRule REQUEST_URI "@beginsWith /_astro/" \ + "id:1000,phase:1,pass,nolog,ctl:ruleEngine=Off" +SecRule REQUEST_URI "@beginsWith /fonts/" \ + "id:1001,phase:1,pass,nolog,ctl:ruleEngine=Off" diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..c4f8f80 --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,50 @@ +# Development override – auto-loaded by `podman compose` / `docker compose` +# when present alongside compose.yaml. Maps Caddy to unprivileged host ports +# so it works under rootless Podman without tweaking +# net.ipv4.ip_unprivileged_port_start. +# +# It also pins static IPs and adds a /etc/hosts override on the Caddy +# container to work around aardvark-dns leaving stale records around between +# rootless `podman compose down` / `up` cycles. Production (Quadlets / `-f +# compose.yaml`) keeps relying on standard container DNS. +# +# Default usage (dev, high ports, plain HTTP): +# podman compose up --build +# → http://localhost:8080 +# +# To skip this override and run with prod (low) ports: +# podman compose -f compose.yaml up --build + +networks: + dev-blog: + ipam: + config: + - subnet: 10.89.99.0/24 + +services: + app: + networks: !override + dev-blog: + ipv4_address: 10.89.99.10 + + caddy: + environment: + # In dev we drive listeners from the Caddyfile itself (see below), so + # SITE_ADDRESS is unused but kept harmless. + SITE_ADDRESS: ${SITE_ADDRESS:-:80} + networks: !override + dev-blog: + ipv4_address: 10.89.99.11 + # Pin `app` to the static IP above so we never depend on aardvark-dns, + # which is prone to caching stale entries between rootless restarts. + extra_hosts: + - "app:10.89.99.10" + # Swap the production Caddyfile for one that uses `tls internal` so + # https://localhost:8443 works without ACME / Cloud DNS credentials. + volumes: + - ./caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro + # !override replaces the ports list from compose.yaml instead of appending. + ports: !override + - "${HTTP_PORT:-8080}:80" + - "${HTTPS_PORT:-8443}:443" + - "${HTTPS_PORT:-8443}:443/udp" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d7bdda8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,84 @@ +# Podman-compose / Docker-compose file for the dev-blog stack. +# +# This is provided as a convenience for local iteration; production deployments +# should use the Quadlet units in ./quadlet/ which integrate with systemd. +# +# Usage: +# podman compose up --build +# +# The Caddy container needs a Google Cloud service-account JSON key mounted at +# /run/secrets/gcp-dns.json for the ACME DNS-01 challenge to work. For local +# HTTP-only development, set SITE_ADDRESS=http://:80 in your shell or .env. + +name: dev-blog + +networks: + dev-blog: + driver: bridge + +volumes: + caddy-data: + caddy-config: + +services: + app: + build: + context: . + dockerfile: Containerfile + image: localhost/dev-blog-app:latest + container_name: dev-blog-app + restart: unless-stopped + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: "4321" + ASTRO_TELEMETRY_DISABLED: "1" + networks: + - dev-blog + expose: + - "4321" + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + caddy: + build: + context: ./caddy + dockerfile: Containerfile + image: localhost/dev-blog-caddy:latest + container_name: dev-blog-caddy + restart: unless-stopped + depends_on: + - app + environment: + SITE_ADDRESS: ${SITE_ADDRESS:-https://example.com} + ACME_EMAIL: ${ACME_EMAIL:-admin@example.com} + GCP_PROJECT: ${GCP_PROJECT:-my-gcp-project} + GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/gcp-dns.json + secrets: + - gcp-dns + networks: + - dev-blog + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + - "${HTTPS_PORT:-443}:443/udp" + volumes: + - caddy-data:/data + - caddy-config:/config + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + +secrets: + gcp-dns: + # Path to a Google Cloud service-account JSON key with permission to + # update Cloud DNS records for the SITE_ADDRESS zone. + file: ./secrets/gcp-dns.json diff --git a/quadlet/caddy-config.volume b/quadlet/caddy-config.volume new file mode 100644 index 0000000..a130a84 --- /dev/null +++ b/quadlet/caddy-config.volume @@ -0,0 +1,8 @@ +[Unit] +Description=Persistent Caddy config cache + +[Volume] +VolumeName=caddy-config + +[Install] +WantedBy=multi-user.target default.target diff --git a/quadlet/caddy-data.volume b/quadlet/caddy-data.volume new file mode 100644 index 0000000..5ed42d5 --- /dev/null +++ b/quadlet/caddy-data.volume @@ -0,0 +1,8 @@ +[Unit] +Description=Persistent Caddy data (certificates, ACME state) + +[Volume] +VolumeName=caddy-data + +[Install] +WantedBy=multi-user.target default.target diff --git a/quadlet/dev-blog-app.container b/quadlet/dev-blog-app.container new file mode 100644 index 0000000..e5551e1 --- /dev/null +++ b/quadlet/dev-blog-app.container @@ -0,0 +1,34 @@ +[Unit] +Description=dev-blog Astro application (node:24-trixie-slim) +Wants=network-online.target +After=network-online.target + +[Container] +ContainerName=dev-blog-app +# Built from the Containerfile at the repo root. +# Build with: podman build -t localhost/dev-blog-app:latest . +Image=localhost/dev-blog-app:latest + +Network=dev-blog.network +NetworkAlias=app + +Environment=NODE_ENV=production +Environment=HOST=0.0.0.0 +Environment=PORT=4321 +Environment=ASTRO_TELEMETRY_DISABLED=1 + +# Hardening +NoNewPrivileges=true +ReadOnly=true +DropCapability=ALL +Tmpfs=/tmp:rw,size=64m,mode=1777 + +# Not exposed publicly – Caddy reverse-proxies in over the internal network. +# PublishPort=4321:4321 + +[Service] +Restart=on-failure +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/quadlet/dev-blog-caddy.container b/quadlet/dev-blog-caddy.container new file mode 100644 index 0000000..0cd892a --- /dev/null +++ b/quadlet/dev-blog-caddy.container @@ -0,0 +1,46 @@ +[Unit] +Description=Caddy reverse proxy with Coraza WAF (OWASP CRS) and Google Cloud DNS plugin +Wants=network-online.target +After=network-online.target dev-blog-app.service +Requires=dev-blog-app.service + +[Container] +ContainerName=dev-blog-caddy +# Build with: podman build -t localhost/dev-blog-caddy:latest ./caddy +Image=localhost/dev-blog-caddy:latest + +Network=dev-blog.network +NetworkAlias=caddy + +PublishPort=80:80 +PublishPort=443:443 +PublishPort=443:443/udp + +# --- Configuration --- +Environment=SITE_ADDRESS=https://example.com +Environment=ACME_EMAIL=admin@example.com +# Required by the googleclouddns plugin; must match a GCP project that owns +# the DNS zone for SITE_ADDRESS. +Environment=GCP_PROJECT=my-gcp-project +# A Workload-Identity / service-account JSON key mounted read-only below. +Environment=GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json + +# Mount the GCP service-account key as a read-only secret. +# Create with: podman secret create gcp-dns-sa /path/to/key.json +Secret=gcp-dns-sa,type=mount,target=gcp-dns.json,mode=0400 + +# Persistent state for ACME certificates and Caddy's data directory. +Volume=caddy-data.volume:/data +Volume=caddy-config.volume:/config + +# Hardening +NoNewPrivileges=true +DropCapability=ALL +AddCapability=CAP_NET_BIND_SERVICE + +[Service] +Restart=on-failure +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/quadlet/dev-blog.network b/quadlet/dev-blog.network new file mode 100644 index 0000000..121cd66 --- /dev/null +++ b/quadlet/dev-blog.network @@ -0,0 +1,10 @@ +[Unit] +Description=Internal network for the dev-blog stack + +[Network] +NetworkName=dev-blog +Driver=bridge +DisableDNS=false + +[Install] +WantedBy=multi-user.target default.target