Hardened the database configuration in the Website service target.

This commit is contained in:
2026-08-10 23:42:22 +02:00
parent a88569ad4c
commit 19fa9d8d8c
4 changed files with 107 additions and 84 deletions
+6 -8
View File
@@ -41,21 +41,19 @@ LOG_LEVEL=debug
DATABASE_DRIVER=inMemory
# PostgreSQL connection, used when DATABASE_DRIVER=postgres.
# `postgres` is the local database's Compose service name; use 127.0.0.1 when
# running the app directly with `swift run`.
DATABASE_HOST=localhost
# Port of the database to connect to.
DATABASE_PORT=5432
# Name of the database to connect to.
DATABASE_NAME=loud
# Username of the database to connect as.
DATABASE_USERNAME=loud
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=loud
# Port of the database to connect to.
DATABASE_PORT=5432
# TLS posture when connecting: off | prefer | require (use `require` in production).
DATABASE_TLS=off
# Username of the database to connect as.
DATABASE_USERNAME=loud
+93 -68
View File
@@ -1,16 +1,17 @@
# Loud Website
The **Loud** 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.
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:
- 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.
- 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).
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) 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.
- 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.
- 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`).
@@ -22,13 +23,13 @@ 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, the `StaticFile` asset catalog, the request context, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
| `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:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
- `Infrastructure` (`Packages/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 pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
- `Persistence` (`Packages/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` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
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).
@@ -42,12 +43,19 @@ LogRequestsMiddleware
→ 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) from
the following sources, **highest precedence first**:
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)
@@ -55,8 +63,10 @@ the following sources, **highest precedence first**:
5. Built-in defaults
The two files play different roles:
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the PostgreSQL backend.
- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
- **`.env`** (git-ignored) holds your deployment values, and is the file the Makefile and Compose read for their `${VAR}` placeholders — it typically selects the PostgreSQL backend.
- **`.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`,
@@ -67,9 +77,9 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
### 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.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=<token>`), 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, SVG). |
| `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
@@ -110,24 +120,38 @@ See [Persistence](#persistence-1) below for the workflow.
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
### Rate limiting
These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
| 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 — otherwise clients can forge it; leave it off when the server is directly reachable. |
| `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. |
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'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `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: it only takes effect over HTTPS (browsers ignore it on plain HTTP) and is "sticky" in browsers, so it must stay off in local HTTP development. It is enabled for production in `docker-compose.yml`, where it only has an effect once traffic is served over HTTPS behind a TLS-terminating proxy.
`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:
@@ -136,7 +160,7 @@ 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 local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=postgres swift run Website` — since process environment variables outrank both files.
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
@@ -145,8 +169,7 @@ 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
```
`make help` lists every available target.
Unlike a direct `swift run`, these targets inherit the exported `.env` values (see [Configuration](#configuration)), so they run against whichever backend `.env` selects. `make help` lists every available target.
## Persistence
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
@@ -155,56 +178,57 @@ The service persists data through Fluent and selects its backend at runtime with
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, a PostgreSQL database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
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 against the configured database, then exit.
# Run the registered migrations, then exit.
swift run Website --database-migrate
# In a container (production), against the managed database:
# 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), so the database survives `db-unmount` and container restarts:
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 # run migrations against it
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 (data kept in Tests/DB)
make db-reset # stop and remove the container
DATABASE_DRIVER=postgres make site-mount # run the site against PostgreSQL
make db-unmount # stop and remove the container, keeping the data
make db-reset # stop and remove the container, and delete the data
```
> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
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` is a readiness check that 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.
`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
`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. `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
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. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`.
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 has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the PostgreSQL integration test is skipped unless a database is pointed at via `POSTGRES_TEST_HOST` (with optional `POSTGRES_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
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
# With the local PostgreSQL up (make db-mount):
cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test
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`).
Verify the image builds for its `linux/amd64` target without tagging or publishing:
```sh
make img-check
```
Build, tag, and push a release to the registry (an explicit version is required):
```sh
make img-release version=1.2.3
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:
@@ -214,22 +238,22 @@ docker compose -f docker-compose.yml up -d
```
### Static assets
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).
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — the output is pixel-identical, only encoded smaller.
- The SVG icon is minified with [svgo](https://github.com/svg/svgo).
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 sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
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.
The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
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`, which is the canonical source — there is no external design file to regenerate from.
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 |
| --- | --- | --- | --- |
@@ -240,7 +264,7 @@ All icons are renditions of the star mark in `icon.svg`, which is the canonical
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. for the 512px rendition:
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 &&
@@ -250,7 +274,7 @@ 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 a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
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`). |
@@ -263,7 +287,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
| `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_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | PostgreSQL connection (when `DATABASE_DRIVER=postgres`). Provide the password via a secret. |
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
| `DATABASE_HOST`, `DATABASE_PASSWORD` | **Mandatory** — the Compose files carry no default for either, since none can be correct: `localhost` inside the container is the container itself, and a blank password authenticates as nobody. Compose refuses to start without them rather than booting a website that serves 503s. Provide the password via a secret. |
| `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME` | The rest of the PostgreSQL connection (when `DATABASE_DRIVER=postgres`); these do default (`5432`, `loud`, `loud`). |
| `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`.
+5 -3
View File
@@ -17,8 +17,12 @@ services:
environment:
LOG_LEVEL: debug
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_HOST: postgres
DATABASE_TLS: ${DATABASE_TLS:-off}
depends_on:
postgres:
condition: service_healthy
required: false
# Local development database, started only with the `database` profile so a plain `docker compose up` still runs the
# in-memory backend:
@@ -45,6 +49,4 @@ services:
retries: 10
start_period: 30s
volumes:
# postgres:18 keeps its data directory under /var/lib/postgresql/<major>/docker, so the whole
# /var/lib/postgresql tree is mounted — not the pre-18 /var/lib/postgresql/data path.
- ./Tests/DB:/var/lib/postgresql
+3 -5
View File
@@ -20,15 +20,13 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
# Persistence: in-memory by default; set DATABASE_DRIVER=postgres to run against a managed PostgreSQL database.
# Provide the password via the environment or a secret — never commit it.
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_NAME: ${DATABASE_NAME:-loud}
DATABASE_USERNAME: ${DATABASE_USERNAME:-loud}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
DATABASE_TLS: ${DATABASE_TLS:-prefer}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
DATABASE_TLS: ${DATABASE_TLS:-require}
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"]
interval: 30s