The **Site** 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.
- 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)).
- Embeds a cookieless [Umami](https://umami.is) tracker on the pages that provide one, configured through the `analytics.*` keys (see [Analytics](#analytics)).
- 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.
| `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`. |
-`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).
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.
Five of those are 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. |
| `analytics` | `<link rel="preconnect">` + a deferred `<script>` | An `Analytics` tracker — script URL, website identifier, reported domains, and the behavior flags, following the [Umami](https://umami.is) `data-` attribute convention. Unlike the structured data it *is* executable, so the `Content-Security-Policy` must allow its origin; with recorder mode on, a second deferred script follows it. The executable builds one from the `analytics.*` keys (see [Analytics](#analytics)). |
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.
- **`.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.
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.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. |
> **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.
| `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.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.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).
`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.
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.
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.
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.
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:
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`:
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.
`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.
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:
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `site` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
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`. `make img-check` pins the build to `linux/amd64`; `make img-release` builds for `IMAGE_PLATFORM`.
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.
`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 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.
`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.
| `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:
(`-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`.)
| `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`, `site`, `site`). |
| `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`.