Site configuration and deployment
The site’s configuration is fixed when the image is built. There is no runtime
configuration and no secret. Each value is validated by the Astro build, so a bad
value stops astro build (and docker build) rather than publishing pages with the
wrong canonical origin or contact address.
Configuration
Section titled “Configuration”| Variable | Required | Rule |
|---|---|---|
SITE_URL | yes | Origin only, no path. https outside localhost. Canonical origin for every URL. |
APP_URL | yes | Origin only. Must differ from SITE_URL. |
CONTACT_EMAIL | public origin | Valid email. Required when SITE_URL is not localhost. |
ANALYTICS_SCRIPT_URL | no | https origin of an analytics script. Set both analytics variables or neither. |
ANALYTICS_WEBSITE_ID | no | 1 to 64 letters, digits, - or _. Sent as data-website-id. |
The validated values reach pages as a global (__SITE_CONFIG__), so pages never read
process.env. astro.config.mjs and the nginx renderer share one implementation,
src/lib/config-core.mjs. The template is in apps/site/.env.example.
Rules that must not regress:
- Invalid configuration throws during
astro build. Nothing validates at container start. - Localhost builds are
noindexandDisallow: /. - The consent banner exists only when analytics is configured.
Production values
Section titled “Production values”The production values live in infra/k3s/overlays/prod/site-build.env. Changing any value
rebuilds the image.
.github/workflows/production-release.ymlreads the file and passes the values as build arguments to the candidate image in the coordinated release..github/workflows/apps-site-release.ymlreads the same file for the Compose and WUD path, and runs on changes to it.- Compose builds the site from
PUBLIC_SITE_HOST,SITE_CONTACT_EMAILand theSITE_ANALYTICS_*variables.
The image
Section titled “The image”apps/site/Dockerfile has four stages:
deps:bun install --frozen-lockfile.dev: the Astro dev server, used by Composesite-devon port 7333.build:astro build, thenrender-nginx.mjs, which writes the nginx configuration with the CSP derived from the same validated configuration.production:nginx:1.31-alpine, pinned by digest, running as the non-root userapp(uid 1001) on port 8080. The build runsnginx -t.
The container writes only to /tmp. Build it and run it read-only:
docker build -t boringstack-site \ --build-arg SITE_URL=https://example.com \ --build-arg APP_URL=https://app.example.com \ --build-arg CONTACT_EMAIL=hello@example.com .docker run --rm --read-only --tmpfs /tmp -p 8080:8080 boringstack-sitecurl -si http://localhost:8080/healthzGET /healthz serves readiness and liveness.
nginx headers and cache
Section titled “nginx headers and cache”Security headers are declared once at server level, because an add_header inside a
location replaces the server’s headers. They are the CSP, X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, Permissions-Policy and Cross-Origin-Opener-Policy.
HSTS is set by the ingress, which terminates TLS.
Cache policy, keyed on status and path:
/_astro/*(hashed assets):public, max-age=31536000, immutable.- Successful images and fonts:
public, max-age=86400. - HTML and everything else:
no-cache. - Errors:
no-store.
Docker Compose
Section titled “Docker Compose”infra/compose/compose/docker-compose.site.yml is merged when WITH_SITE=1. The default is
1 in dev and prod. WITH_SITE=0 opts out and keeps the single-domain layout, with the app on
PUBLIC_UI_HOST. dev.sh applies it.
- Dev:
site-devonSITE_HOST_PORT(default7333). - Prod:
siteis routed by Traefik onHost(PUBLIC_SITE_HOST)(the apex). The app is onPUBLIC_UI_HOST(app.<domain>), so the two origins never share cookies.dev.shrequiresPUBLIC_SITE_HOST(different from the app host),SITE_CONTACT_EMAILand a pinnedSITE_IMAGE_TAG(a version orsha-*, neverlatest). A full pinnedSITE_IMAGEreference can replace the tag.
infra/compose/scripts/validate-guardrails.sh renders the site overlay with the other prod
services, so the healthcheck and no-new-privileges checks apply to site too.
Kubernetes (k3s)
Section titled “Kubernetes (k3s)”infra/k3s/base/site holds the Deployment, Service, NetworkPolicy and ServiceAccount. The
Deployment has a read-only root filesystem, drops all capabilities, runs as uid 1001,
probes /healthz, and mounts /tmp as an emptyDir. The NetworkPolicy allows ingress
from Traefik and DNS egress only.
The prod overlay routes:
Host(<domain>)tosite:8080(IngressRouteboringstack-site).Host(app.<domain>)toapifor/apiand/health, and touifor everything else.- The certificate covers the apex,
app.<domain>and the GlitchTip host.
The site is in sync wave 1, with the UI, after the API is healthy. site-pdb keeps one
replica available, and patches/site-replicas.yaml runs two replicas with spread
constraints.
The image is ghcr.io/<owner>/<project>-site. Its digest is pinned in
infra/k3s/overlays/prod/kustomization.yaml by the coordinated release, together with the
API, migrations and UI digests. See Production releases.
The latest and sha-* tags that apps-site-release.yml publishes are for the Compose
path only. Kubernetes never references them.
.github/workflows/apps-site-validate.yml runs bun run validate, builds the same image and
smoke-tests it read-only.