Files
ccn/Services/Website/README.md
T

368 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Site Website
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.
## 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):
| Property | Renders as | Notes |
| --- | --- | --- |
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
| `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**:
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) 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.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
| `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.
### 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` | `SiteWebsite` | Server name and logger label. |
### HTTPS redirect
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
- **`301`, not `302`** — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
- **Target built from `site.origin`, not the `Host` header** — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
- **`/.well-known/` is exempt** — redirecting the ACME challenge path breaks certificate renewal.
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/mr-rock` and `/mr-rock/` alike, so
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
`GET` and drop the body.
### 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` | `site` | Database name. Ignored for `inMemory`. |
| `database.username` | `DATABASE_USERNAME` | `site` | 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. |
### 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.
3. Set `ANALYTICS_WEBSITE_ID` on the deployment.
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `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`.
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; 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. [`https.trustForwardedProto`](#https-redirect) carries the same caveat: a cached `301` is as sticky as an HSTS commitment.
## 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 `site` (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
```sh
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
```
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 '
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_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`). |
| `LOG_LEVEL` | Runtime log level (default `info`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
| `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`, `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`.