working container config

This commit is contained in:
Jason Ross
2026-04-29 15:44:47 -05:00
parent ca89a810cc
commit 5219e3131d
14 changed files with 586 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.github
.vscode
node_modules
dist
.astro
coverage
playwright-report
test-results
e2e
**/*.log
.env
.env.*
README.md
+92
View File
@@ -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.).
+50
View File
@@ -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"]
+70
View File
@@ -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
}
}
+58
View File
@@ -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
}
}
+39
View File
@@ -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
+23
View File
@@ -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"
+50
View File
@@ -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"
+84
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=Persistent Caddy config cache
[Volume]
VolumeName=caddy-config
[Install]
WantedBy=multi-user.target default.target
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=Persistent Caddy data (certificates, ACME state)
[Volume]
VolumeName=caddy-data
[Install]
WantedBy=multi-user.target default.target
+34
View File
@@ -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
+46
View File
@@ -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
+10
View File
@@ -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