The **CCN** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets.
## Overview
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
- Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)).
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Docker (optional) for the containerized run/deploy workflow.
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
## Architecture
Two SwiftPM targets:
| Target | Kind | Path | Role |
| --- | --- | --- | --- |
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
The `Website` executable depends on four local packages, each under `Packages/`:
-`Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
-`Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
-`Persistence` — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
-`Utility` — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
```
LogRequestsMiddleware
→ SecurityHeadersMiddleware (security headers on every response)
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
→ LocalizationMiddleware (negotiates the request's language)
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
### Page metadata
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
3. A `.env.local` file in the working directory (optional)
4. A `.env` file in the working directory (optional)
5. Built-in defaults
The two files play different roles:
- **`.env`** (git-ignored) holds your deployment values — including the database password — and is the file the Makefile and Compose read for their `${VAR}` placeholders; it typically selects the PostgreSQL backend. Keep it out of version control and off shared machines: Compose passes its values to the container as environment variables, so anything in it is readable through `docker inspect` and by every process in the container.
- **`.env.local`** (tracked) holds the local development overrides: in-memory database, `debug` logging. Sitting *above*`.env`, it keeps a direct launch (`swift run` or a debugger) on the local values even when `.env` points at a deployment. Compose never reads it, and the production image does not ship it.
The Makefile `include`s `.env` and exports every value, so a target launched through `make` runs with the deployment configuration rather than the `.env.local` one: `make site-run` uses the backend `.env` selects, a bare `swift run Website` the in-memory one. And because a makefile assignment outranks an inherited environment variable, overriding a value for a single invocation takes a command-line variable *after* the target (`make site-mount DATABASE_DRIVER=postgres`) — an environment prefix is silently discarded.
### Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. |
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for the remaining `text/*` assets (e.g. `robots.txt`), which keep unversioned URLs; also marked `must-revalidate`. |
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else — including `site.webmanifest` (`application/manifest+json`) and `sitemap.xml` (`application/xml`), neither of which is `text/*`. |
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`) are resolved before the general `text/*` category.
| `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. |
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Seconds a query waits for a pooled connection before failing. Ignored for `inMemory`. |
> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to `database.pool.timeout` before failing.
See [Persistence](#persistence-1) below for the workflow.
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
### Analytics
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
1. Check `String.Analytics.origin` (`Sources/Library/Public/Extensions/String+Constants.swift`). It ships as `https://analytics.rock-n-code.com`, the platform's shared [Umami](https://umami.is) instance; point it elsewhere if this site reports to another one. The origin alone tracks nothing — the tracker is omitted entirely while `analytics.websiteID` is empty.
2. Extend `security.contentSecurityPolicy` to allow that origin in `script-src` and `connect-src` — the default policy is `'self'`-only, so the tracker is blocked until you do.
| `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | _(empty — analytics off)_ | The analytics website identifier the tracker on both pages reports as. While it is empty the tracker script is omitted entirely; clearing it again disables analytics on a deployment. |
| `analytics.domains` | `ANALYTICS_DOMAINS` | _(empty — every host reports)_ | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. Left empty, the attribute is omitted and no host is filtered out. |
| `analytics.recorder` | `ANALYTICS_RECORDER` | `false` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Session recording is the most invasive thing the tracker does, so it is opted into: set it to `true` to enable it on a deployment. |
The tracker's origin is not a configuration key: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) cannot drift apart at runtime. The pages emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
> **Set `analytics.domains` to the host the deployment actually serves, or leave it empty.** It is an allowlist: name a host the deployment does not serve (say, pointing a staging box at the production domain) and every visit is dropped silently, with no error. To turn analytics off instead, clear `analytics.websiteID`.
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.
## Running locally
Directly with Swift:
```sh
swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
```
A direct run picks up the `.env.local` development overrides (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — `DATABASE_DRIVER=postgres swift run Website` — since process environment variables outrank both files.
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
make pkg-build # swift build
make pkg-outdated # list the SPM dependencies that can be updated
make pkg-update # update the SPM dependencies
make site-run # run locally with hot reload (hb watch)
make site-mount # docker compose up --build --detach
make site-unmount # docker compose down + remove the local image
```
Unlike a direct `swift run`, these targets inherit the exported `.env` values (see [Configuration](#configuration)), so they run against whichever backend `.env` selects. The one exception is `DATABASE_HOST` under `make site-mount`: the local Compose override pins it to the `postgres` service name, since the `.env` value addresses the database from the host rather than from inside the container. `make help` lists every available target.
## Persistence
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
### In-memory (default)
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
### PostgreSQL
Set `DATABASE_DRIVER=postgres` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, PostgreSQL is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
```sh
# Run the registered migrations, then exit.
swift run Website --database-migrate
# The same, in a container against the managed database.
docker compose -f docker-compose.yml run --rm website --database-migrate
```
A local PostgreSQL for development lives behind the `database` Compose profile, so a plain `docker compose up` still runs in-memory. Its data directory is bind-mounted to `Tests/DB` (git-ignored) and initialised once from the `DATABASE_NAME`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` values in `.env`:
```sh
make db-mount # start PostgreSQL (docker compose --profile database up --wait postgres)
make db-migrate # migrate it, from the host (forced to 127.0.0.1 with TLS off)
make db-shell # open a SQL shell on it (psql)
make db-unmount # stop and remove the container, keeping the data
make db-reset # stop and remove the container, and delete the data
```
Then run the site against it, on the host or in its container:
```sh
make site-run # hot reload on the host, via localhost:5432
make site-mount # containerised, via the Compose service name
curl -i localhost:8080/health/ready # 200 once the database is reachable
```
The containerised run needs no `DATABASE_HOST`: `docker-compose.override.yml` pins it to `postgres`, the Compose service name, which is the only address that resolves from inside the network. The `.env` value is the *host machine's* view (`localhost`) and is left to `make site-run`, which does run on the host. The override also `depends_on` the database, so with the `database` profile enabled the website waits for PostgreSQL to pass its healthcheck; without the profile it still starts alone on the in-memory backend.
> **Note:** every other `DATABASE_*` override must be passed *after* the target — `DATABASE_TLS=off make site-mount` is silently discarded (see [Configuration](#configuration)), `make site-mount DATABASE_TLS=off` is not.
> **Note:** `db-reset` deletes `Tests/DB` itself, because Compose's `--volumes` flag cannot clear a bind mount. Use it to start from an empty database — for instance after changing `DATABASE_PASSWORD`, which is only read when the cluster is first initialised.
### Health checks
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure. A hanging database is reported as not ready within the probe's 2-second deadline, so the route itself never stalls. `docker-compose.yml` points the `website` container healthcheck at `/health`, keeping container health decoupled from database reachability.
## Testing
```sh
make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
```
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. `make pkg-test` runs the service package's own two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
`Tests/Website.xctestplan` — the plan the `Site.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
The `Persistence` package also has its own suite, run from `Packages/Persistence`. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at a database, so `swift test` stays runnable without one:
```sh
cd ../../Packages/Persistence && swift test# in-memory only
cd ../../Packages/Persistence &&POSTGRES_TEST_HOST=127.0.0.1 swift test# against make db-mount
```
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `ccn` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
## Deployment
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`.
`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment.
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason).
### Base images
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
The executable is the `ENTRYPOINT` and its serving flags are the `CMD`:
```dockerfile
ENTRYPOINT["./Website"]
CMD["--http-host","0.0.0.0","--http-port","8080"]
```
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
```sh
make img-check # verify it builds for IMAGE_PLATFORM, without tagging or publishing
make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required)
```
Pull and run the prebuilt image in production — the `-f docker-compose.yml` flag is important, as it skips the local-development override:
```sh
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
### Static assets
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller.
- SVGs are minified with [svgo](https://github.com/svg/svgo), recursively.
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
```sh
make ast-minify
```
#### Crawler files
`robots.txt` and `sitemap.xml` need an absolute origin, which the template ships as the placeholder `https://site.example.com` — an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) reserved domain, so an un-bootstrapped copy can never point a crawler at a real site. The bootstrap script prompts for the canonical site URL and rewrites both files with it; it warns if the placeholder is left in place.
To change the origin later, edit the `Sitemap:` line in `robots.txt` and the `<loc>` entries in `sitemap.xml`. Add a `<loc>` per public page as the site grows — nothing generates the sitemap at runtime.
> **Still manual:** `site.webmanifest` ships empty `name` / `short_name` fields; bootstrap does not fill them in.
### Icons
All icons are renditions of the star mark in `icon.svg`, the canonical source — there is no external design file to regenerate from.
| File | Size | Used by | Dark mode |
| --- | --- | --- | --- |
| `icon.svg` | vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded `prefers-color-scheme` style flips the accent paths from `#000` to `#f3ecf5`. |
| `favicon.ico` | 32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral **by design**: it is the orange star *without* the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas: `#fafafa` unqualified, then `#0c0710` qualified with `(prefers-color-scheme: dark)`.
> **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect.
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. the 512px rendition:
```sh
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
### Required variables
The Makefile and Compose files read these from `.env` (or the environment). Provide your own values — do **not** commit secrets.
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. `IMAGE_TAG` is the fallback for `make img-release`'s `version=` argument. |
| `IMAGE_PLATFORM` | Deployment architecture (default `linux/amd64`). Drives `make img-check`, `make img-release`, and the `platform:` the production Compose file runs the pulled image with — keep it matched to the deployment host. |
| `BUILD_PLATFORM` | Architecture of the *local* development build only (default `linux/arm64`); set it to `linux/amd64` on an Intel Mac or an amd64 Linux box. Never used by the production Compose file. |
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `CCNWebsite`). |
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
| `DATABASE_DRIVER` | `inMemory` or `postgres`. The production Compose file defaults it to `postgres`; the local override defaults back to the in-memory backend. |
| `DATABASE_HOST`, `DATABASE_PASSWORD` | **Mandatory** — the production Compose file carries no default for either, since none can be correct: `localhost` inside the container is the container itself, and a blank password authenticates as nobody. It refuses to start without them rather than booting a website that serves 503s. Both come from `.env`, which is git-ignored — never commit the password. Compose interpolates each file before merging, so both must be set for a local `docker compose up` too, even though the override pins the host. |
| `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME` | The rest of the PostgreSQL connection (when `DATABASE_DRIVER=postgres`); these do default (`5432`, `ccn`, `ccn`). |
| `DATABASE_POOL_MAX_PER_EVENT_LOOP` | Pooled connections per event loop (default `4`) — see the [connection budget](#persistence) before scaling out. |
| `DATABASE_TLS` | TLS posture: `off`, `prefer`, or `require`. The production Compose file defaults it to `require`, which refuses a server offering no TLS; the local override defaults it to `off` for the plaintext development container. `prefer` continues in plaintext when the upgrade is stripped, handing over the password — so it is not a safe production posture. |
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.