# Loud Website The **Loud** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page, registers newsletter subscriptions, and serves the site's static assets. ## Overview The service: - Renders the landing page at `GET /` with [Elementary](https://github.com/elementary-swift/elementary), cached once per supported language; its canonical URL, Open Graph/Twitter card, and JSON-LD structured data all derive from the configured site origin. - Negotiates each request's language from `Accept-Language` against the `WebsiteLibrary` String Catalog (falling back to `en`), serving from the per-language cache with `Content-Language` and `Vary: Accept-Language`. - Registers newsletter subscriptions at `POST /subscribe`: form-encoded, validated and normalized, honeypot-guarded, and stored tagged with the negotiated language. - Answers `GET /health` (liveness, static JSON) and `GET /health/ready` (readiness — `200`/`503` on database reachability). - Serves static files from `Resources/Static` via Hummingbird's `FileMiddleware` with media-type-specific `Cache-Control`; the production image ships optimized copies (see [Static assets](#static-assets)). - Returns a custom 404 page, localized like the landing page, for anything matching neither a route nor a static file. - Embeds a cookieless [Umami](https://umami.is) tracker on both pages when configured — pageviews plus subscription, outbound Instagram, and 404 recovery events (see [Analytics](#analytics)). - Compresses responses (gzip/deflate) above a configurable size, and stamps a hardened set of security headers on every response. - Persists through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or PostgreSQL, 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 and their shared chrome fragments, the `StaticFile` asset catalog, the request context, the subscription form model, 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: declarative routing (`RouterController`, `addController`), the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` metadata types, the pre-rendered localized HTML responses, and `FingerprintAssets`. The service supplies its own specifics (String Catalog, pages, icons, analytics) through the `*+Defaults` extensions and `ConfigReader` properties in `WebsiteLibrary` and the executable. - `Persistence` — the Fluent data layer: the `Driver` selector, the `Service` factory, the `PrepareDB` migration registrar, and the `Probe` behind the readiness check. `Subscriber` and the repository types are public; record models and migrations stay internal. It has no `swift-configuration` dependency — the executable maps the `database.*` keys onto the driver. - `Utility` — shared helpers with no server dependencies, such as the `NormalizeEmail` the subscription flow canonicalizes addresses with. 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) SubscriptionController (POST /subscribe → newsletter subscription) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` ## Pages Both pages are rendered with Elementary and share the site chrome — the sticky top bar, the mobile drawer, and the footer — through the `Chrome` fragment, styled by `/css/shared.css` and driven by `/js/shared.js`. Every script is a progressive enhancement, loaded `defer`red from the document head: the pages stay fully usable without JavaScript. - **Landing page** (`IndexPage`): the site's sections plus the newsletter subscription forms, which validate inline and post with `fetch`, falling back to a plain form submission without JavaScript. - **Not-found page** (`NotFoundPage`): a "dead channel" stage — animated canvas static, CRT scanlines, a vignette, and a glitching 404 — with a Web Audio white-noise toggle that appears only when the script and the API are available. The decoration layers are pinned to the page, not the viewport, so nothing paints past its edges; the toggle's play/stop icons are inline vectors (the `▶`/`◼` glyphs render as emoji on iOS); and the loops stop under `prefers-reduced-motion` and disappear under forced colors. The chrome keeps only the brand, the theme toggles, and the "back to home" link here — with no section links to navigate, its top bar, drawer, and footer render as plain containers rather than `nav` landmarks. - **Shared behaviors**: a "Skip to content" link, visually hidden until focused, is each page's first focusable element, jumping keyboard and screen-reader users past the chrome; the theme toggle follows the system appearance until a choice is made, then stores it for the session; the footer's back-to-top link hides itself when the page has nothing to scroll; and the page wrapper bleeds the chrome's base color past both ends of the document, so rubber-band overscroll reads as the top bar or footer stretching rather than a seam. - **Analytics events**: beyond pageviews — `subscribe` and `subscribe_error` (tagged with which form, and the failure reason, never the address), `instagram` clicks (tagged with the profile, via declarative `data-umami-event` attributes), and `notfound_recover`. Every JavaScript call is guarded, so the pages behave identically when the tracker is absent, blocked, or suppressed by Do Not Track. ## Configuration Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**: 1. Command-line arguments (e.g. `--http-host 0.0.0.0`) 2. Process environment variables 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`, `security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` → `CACHE_MAX_AGE_TEXT`. > To disable a header or override a value, leave the variable **unset** to fall back to the default. A > variable that is set but **blank** is treated as an explicit empty value, not as "use the default". ### Static file caching | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS, MP4 video) and fonts; also marked `immutable`. The pages reference these through content-versioned URLs (`?v=`), so a deploy busts them by changing the URL. | | `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for unversioned text assets (e.g. `robots.txt`); also marked `must-revalidate`. | | `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, JPEG, SVG). | | `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). | ### Response compression | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. | ### HTTP server | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `http.host` | `HTTP_HOST` | _none_ | Host the server binds to. Supplied via the `--http-host` CLI flag (the Docker image passes `0.0.0.0`). | | `http.port` | `HTTP_PORT` | _none_ | Port the server listens on. Supplied via the `--http-port` CLI flag (the Docker image passes `8080`). | | `http.serverName` | `HTTP_SERVER_NAME` | `LoudWebsite` | Server name and logger label. | ### Logging | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `log.level` | `LOG_LEVEL` | `info` | Minimum log level. | ### Persistence | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). | | `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. | | `database.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. | | `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. | | `database.name` | `DATABASE_NAME` | `loud` | Database name. Ignored for `inMemory`. | | `database.username` | `DATABASE_USERNAME` | `loud` | Database username. Ignored for `inMemory`. | | `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. ### Paths | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. | ### Rate limiting | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `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. | ### Site | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `site.origin` | `SITE_ORIGIN` | `https://loud.amsterdam` | Public origin the site is served at (scheme and host, no trailing slash). The pages derive their canonical URL and other absolute links (social card image, structured data) from it, so a staging deployment can point it at itself instead of leaking the production origin into its markup. | ### Analytics | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | | `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | `f28681d6-20e8-43f3-9c3b-5d6a0f8e0591` | The analytics website identifier the tracker on both pages reports as. **Set it to an empty string to disable analytics entirely** — the tracker script is then omitted from the pages. | | `analytics.domains` | `ANALYTICS_DOMAINS` | `loud.amsterdam` | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. | | `analytics.recorder` | `ANALYTICS_RECORDER` | `true` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Set it to `false` to disable session recording on a deployment. | The tracker's origin (`https://analytics.rock-n-code.com`) is not configurable: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) always agree. The pages also emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script. > **Keep `analytics.domains` in sync with `site.origin`.** Both encode the deployment's public host — the hosts the tracker reports from, and the host the pages are served at. Override one without the other (say, pointing a staging deployment at itself) and the domain filter stops matching: every visit is dropped silently, with no error. To disable analytics on a deployment instead, clear `analytics.websiteID` (see above). ### Security headers | Config key | Environment variable | Default | | --- | --- | --- | | `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` | | `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` | | `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` | | `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | | `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` | | `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ | `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 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 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. `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (executable/integration) and `WebsiteLibraryTests` (library units) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. 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 `loud` (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 for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`). ```sh make img-check # verify it builds for linux/amd64, 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 The image build optimizes the files under `Resources/Static` in place, in its `assets` stage, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit: - CSS and JS minified with [esbuild](https://esbuild.github.io). - PNGs losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — pixel-identical, only encoded smaller. - The SVG icon minified with [svgo](https://github.com/svg/svgo). - JPEG metadata stripped with [jpegoptim](https://github.com/tjko/jpegoptim) — pixel data untouched. - MP4s remuxed with [ffmpeg](https://ffmpeg.org) so the `moov` index atom leads the file (faststart) and playback can start before the download finishes; streams are copied, not re-encoded. 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 ``` ### 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 landing page pairs the icons with two `theme-color` metas: `#0c0710` (media-qualified for dark, listed first — the first matching entry wins) and `#fafafa` as the fallback. 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 ' apk add --no-cache imagemagick librsvg oxipng && magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png && oxipng --opt max --strip safe /work/icon-512.png' ``` (`-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. | Variable | Used for | | --- | --- | | `HOST_CONTAINER` | Container registry host (e.g. `registry.example.com`). | | `HOST_OWNER` | Registry namespace / owner. | | `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. | | `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. | | `IMAGE_PLATFORM` | Build platform (e.g. `linux/amd64`). | | `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). | | `LOG_LEVEL` | Runtime log level (default `info`). | | `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). | | `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`, `loud`, `loud`). | | `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`.