Loud Website
The Loud public website service — a Hummingbird server that renders a static landing page, registers newsletter subscriptions, and serves the site's static assets.
Overview
The service:
- Renders the landing page at
GET /with Elementary, 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-Languageagainst theWebsiteLibraryString Catalog (falling back toen), serving from the per-language cache withContent-LanguageandVary: 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) andGET /health/ready(readiness —200/503on database reachability). - Serves static files from
Resources/Staticvia Hummingbird'sFileMiddlewarewith media-type-specificCache-Control; the production image ships optimized copies (see 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 tracker on both pages when configured — pageviews plus subscription, outbound Instagram, and 404 recovery events (see Analytics).
- Compresses responses (gzip/deflate) above a configurable size, and stamps a hardened set of security headers on every response.
- Persists through Fluent, against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or PostgreSQL, selected by a single configuration key.
Requirements
- Swift 6.3 toolchain (
swift-tools-version:6.3). - Docker (optional) for the containerized run/deploy workflow.
- The Hummingbird CLI (
hb) — optional, only formake site-run(watch and rebuild on change).
Architecture
Two SwiftPM targets:
| Target | Kind | Path | Role |
|---|---|---|---|
Website |
executable | Sources/App |
Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
WebsiteLibrary |
library | Sources/Library |
Controllers, the pages and their shared chrome fragments, the StaticFile asset catalog, the request context, the subscription form model, and the *+Defaults extensions and configuration-key constants that supply the site's specifics to Infrastructure. |
The Website executable depends on four local packages, each under Packages/:
Localization— theLocalizeandNegotiatehelpers and theLanguageListof catalog languages (used byWebsiteLibrary).Infrastructure— the shared Hummingbird toolkit: declarative routing (RouterController,addController), the security/vary/rate-limit/localization/not-found middlewares, thePageandAssetscaffolding, theSocialCard/StructuredData/Analyticsmetadata types, the pre-rendered localized HTML responses, andFingerprintAssets. The service supplies its own specifics (String Catalog, pages, icons, analytics) through the*+Defaultsextensions andConfigReaderproperties inWebsiteLibraryand the executable.Persistence— the Fluent data layer: theDriverselector, theServicefactory, thePrepareDBmigration registrar, and theProbebehind the readiness check.Subscriberand the repository types are public; record models and migrations stay internal. It has noswift-configurationdependency — the executable maps thedatabase.*keys onto the driver.Utility— shared helpers with no server dependencies, such as theNormalizeEmailthe subscription flow canonicalizes addresses with.
The persistence backend runs as a Fluent service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
LogRequestsMiddleware
→ SecurityHeadersMiddleware (security headers on every response)
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
→ LocalizationMiddleware (negotiates the request's language)
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
SubscriptionController (POST /subscribe → newsletter subscription)
HealthController (GET /health → liveness, GET /health/ready → readiness)
Pages
Both pages are rendered with Elementary and share the site chrome — the sticky top bar, the mobile drawer, and the footer — through the Chrome fragment, styled by /css/shared.css and driven by /js/shared.js. Every script is a progressive enhancement, loaded deferred 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 withfetch, 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 underprefers-reduced-motionand 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 thannavlandmarks. - 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 —
subscribeandsubscribe_error(tagged with which form, and the failure reason, never the address),instagramclicks (tagged with the profile, via declarativedata-umami-eventattributes), andnotfound_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, highest precedence first:
- Command-line arguments (e.g.
--http-host 0.0.0.0) - Process environment variables
- A
.env.localfile in the working directory (optional) - A
.envfile in the working directory (optional) - 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 throughdocker inspectand by every process in the container..env.local(tracked) holds the local development overrides: in-memory database,debuglogging. Sitting above.env, it keeps a direct launch (swift runor a debugger) on the local values even when.envpoints at a deployment. Compose never reads it, and the production image does not ship it.
The Makefile includes .env and exports every value, so a target launched through make runs with the deployment configuration rather than the .env.local one: make site-run uses the backend .env selects, a bare swift run Website the in-memory one. And because a makefile assignment outranks an inherited environment variable, overriding a value for a single invocation takes a command-line variable after the target (make site-mount DATABASE_DRIVER=postgres) — an environment prefix is silently discarded.
Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with _. For example http.serverName → HTTP_SERVER_NAME,
security.strictTransportSecurity → SECURITY_STRICT_TRANSPORT_SECURITY, cache.maxAge.text → CACHE_MAX_AGE_TEXT.
To disable a header or override a value, leave the variable unset to fall back to the default. A variable that is set but blank is treated as an explicit empty value, not as "use the default".
Static file caching
| Config key | Environment variable | Default | Description |
|---|---|---|---|
cache.maxAge.asset |
CACHE_MAX_AGE_ASSET |
31536000 (1 year) |
max-age for fingerprinted assets (CSS, JS, MP4 video) and fonts; also marked immutable. The pages reference these through content-versioned URLs (?v=<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, JPEG, SVG). |
cache.maxAge.default |
CACHE_MAX_AGE_DEFAULT |
86400 (1 day) |
max-age for everything else (e.g. the web manifest). |
Response compression
| Config key | Environment variable | Default | Description |
|---|---|---|---|
compression.minimumResponseSize |
COMPRESSION_MINIMUM_RESPONSE_SIZE |
1024 |
Minimum response body size, in bytes, before compression is applied. |
HTTP server
| Config key | Environment variable | Default | Description |
|---|---|---|---|
http.host |
HTTP_HOST |
none | Host the server binds to. Supplied via the --http-host CLI flag (the Docker image passes 0.0.0.0). |
http.port |
HTTP_PORT |
none | Port the server listens on. Supplied via the --http-port CLI flag (the Docker image passes 8080). |
http.serverName |
HTTP_SERVER_NAME |
LoudWebsite |
Server name and logger label. |
Logging
| Config key | Environment variable | Default | Description |
|---|---|---|---|
log.level |
LOG_LEVEL |
info |
Minimum log level. |
Persistence
| Config key | Environment variable | Default | Description |
|---|---|---|---|
database.driver |
DATABASE_DRIVER |
inMemory |
Backend: inMemory (ephemeral SQLite, no infrastructure) or postgres (PostgreSQL). |
database.migrate |
DATABASE_MIGRATE (flag --database-migrate) |
false |
When set, run the migrations and exit instead of serving. |
database.host |
DATABASE_HOST |
localhost |
PostgreSQL host. Ignored for inMemory. |
database.port |
DATABASE_PORT |
5432 |
PostgreSQL port. Ignored for inMemory. |
database.name |
DATABASE_NAME |
loud |
Database name. Ignored for inMemory. |
database.username |
DATABASE_USERNAME |
loud |
Database username. Ignored for inMemory. |
database.password |
DATABASE_PASSWORD |
(empty) | Database password. Provide via the environment/a secret — never commit it. |
database.tls |
DATABASE_TLS |
prefer |
TLS posture when connecting: off, prefer, or require. Ignored for inMemory. |
database.pool.maxPerEventLoop |
DATABASE_POOL_MAX_PER_EVENT_LOOP |
4 |
Maximum pooled connections per event loop. Ignored for inMemory. |
database.pool.timeout |
DATABASE_POOL_TIMEOUT |
10 |
Seconds a query waits for a pooled connection before failing. Ignored for inMemory. |
Connection budget: the pool holds
database.pool.maxPerEventLoopconnections 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 defaultmax_connectionsof 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up todatabase.pool.timeoutbefore failing.
See Persistence below for the workflow.
Paths
| Config key | Environment variable | Default | Description |
|---|---|---|---|
path.staticFiles |
PATH_STATIC_FILES |
Resources/Static |
Directory, relative to the working directory, that static files are served from. |
Rate limiting
| Config key | Environment variable | Default | Description |
|---|---|---|---|
rateLimit.limit |
RATELIMIT_LIMIT |
5 |
Requests admitted per client per window on the subscribe endpoint; the excess is answered with 429 Too Many Requests and a Retry-After header. |
rateLimit.window |
RATELIMIT_WINDOW |
60 |
Window length, in seconds, the limit applies to. |
rateLimit.trustForwardedFor |
RATELIMIT_TRUST_FORWARDED_FOR |
false |
Key clients by the first X-Forwarded-For entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
Site
| Config key | Environment variable | Default | Description |
|---|---|---|---|
site.origin |
SITE_ORIGIN |
https://loud.amsterdam |
Public origin the site is served at (scheme and host, no trailing slash). The pages derive their canonical URL and other absolute links (social card image, structured data) from it, so a staging deployment can point it at itself instead of leaking the production origin into its markup. |
Analytics
| Config key | Environment variable | Default | Description |
|---|---|---|---|
analytics.websiteID |
ANALYTICS_WEBSITE_ID |
f28681d6-20e8-43f3-9c3b-5d6a0f8e0591 |
The analytics website identifier the tracker on both pages reports as. Set it to an empty string to disable analytics entirely — the tracker script is then omitted from the pages. |
analytics.domains |
ANALYTICS_DOMAINS |
loud.amsterdam |
Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. |
analytics.recorder |
ANALYTICS_RECORDER |
true |
Whether the pages also embed the session recorder script (recorder.js, loaded from the tracker's origin) alongside the tracker. Set it to false to disable session recording on a deployment. |
The tracker's origin (https://analytics.rock-n-code.com) is not configurable: it is single-sourced in code so the tracker tag and the Content-Security-Policy that must allow it (security.contentSecurityPolicy below) always agree. The pages also emit a preconnect hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
Keep
analytics.domainsin sync withsite.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, clearanalytics.websiteID(see above).
Security headers
| Config key | Environment variable | Default |
|---|---|---|
security.contentSecurityPolicy |
SECURITY_CONTENT_SECURITY_POLICY |
default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none' |
security.contentTypeOptions |
SECURITY_CONTENT_TYPE_OPTIONS |
nosniff |
security.frameOptions |
SECURITY_FRAME_OPTIONS |
DENY |
security.referrerPolicy |
SECURITY_REFERRER_POLICY |
strict-origin-when-cross-origin |
security.permissionsPolicy |
SECURITY_PERMISSIONS_POLICY |
accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=() |
security.strictTransportSecurity |
SECURITY_STRICT_TRANSPORT_SECURITY |
none (omitted) |
Strict-Transport-Security has no default and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. docker-compose.yml enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.
Running locally
Directly with Swift:
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):
make pkg-build # swift build
make site-run # run locally with hot reload (hb watch)
make site-mount # docker compose up --build --detach
make site-unmount # docker compose down + remove the local image
Unlike a direct swift run, these targets inherit the exported .env values (see Configuration), 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:
# 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:
make db-mount # start PostgreSQL
make db-migrate # migrate it, from the host (forced to 127.0.0.1 with TLS off)
make db-shell # open a SQL shell on it (psql)
make db-unmount # stop and remove the container, keeping the data
make db-reset # stop and remove the container, and delete the data
Then run the site against it, on the host or in its container:
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-mountis silently discarded (see Configuration),make site-mount DATABASE_TLS=offis not.
Note:
db-resetdeletesTests/DBitself, because Compose's--volumesflag cannot clear a bind mount. Use it to start from an empty database — for instance after changingDATABASE_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
make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
Tests use the Swift Testing framework. Tests/Website.xctestplan covers the service's two targets — WebsiteTests (executable/integration) and WebsiteLibraryTests (library units) — plus the local packages' suites: InfrastructureTests, PersistenceTests, LocalizationTests, and UtilityTests.
The Persistence package also has its own suite, run from Packages/Persistence. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless POSTGRES_TEST_HOST points at a database, so swift test stays runnable without one:
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).
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:
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
Static assets
The image build optimizes the files under Resources/Static in place, in its assets stage, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit:
- CSS and JS minified with esbuild.
- PNGs losslessly recompressed with oxipng — pixel-identical, only encoded smaller.
- The SVG icon minified with svgo.
- JPEG metadata stripped with jpegoptim — pixel data untouched.
- MP4s remuxed with ffmpeg so the
moovindex atom leads the file (faststart) and playback can start before the download finishes; streams are copied, not re-encoded.
Files keep their names and paths, so the URLs derived from the StaticFile enumeration are unaffected. The repository sources stay readable and unminified: a direct swift run serves them as-is, while any image build — including the local make site-mount one — serves the optimized copies. Assets are copied from the assets stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
Preview the optimized output locally (Docker only, writes to the git-ignored .build/minified):
make ast-minify
Icons
All icons are renditions of the star mark in icon.svg, the canonical source — there is no external design file to regenerate from.
| File | Size | Used by | Dark mode |
|---|---|---|---|
icon.svg |
vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded prefers-color-scheme style flips the accent paths from #000 to #f3ecf5. |
favicon.ico |
32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral by design: it is the orange star without the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
icon-192.png, icon-512.png |
192/512 | site.webmanifest install icons and splash screens |
None (no platform mechanism); full artwork, light rendering, on a transparent background. |
apple-touch-icon.png |
180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
The landing page pairs the icons with two theme-color metas: #0c0710 (media-qualified for dark, listed first — the first matching entry wins) and #fafafa as the fallback.
The raster icons are committed binaries, regenerated from icon.svg on demand via a throwaway container (no local toolchain needed) — e.g. the 512px rendition:
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
apk add --no-cache imagemagick librsvg oxipng &&
magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
oxipng --opt max --strip safe /work/icon-512.png'
(-density scales the 192px viewBox: 96 × target ÷ 192. For favicon.ico, rasterize a star-only copy of the SVG at 32px and pack it with icotool -c --raw.)
Required variables
The Makefile and Compose files read these from .env (or the environment). Provide your own values — do not commit secrets.
| Variable | Used for |
|---|---|
HOST_CONTAINER |
Container registry host (e.g. registry.example.com). |
HOST_OWNER |
Registry namespace / owner. |
HOST_USER, HOST_PASSWORD |
Registry credentials for make img-release. |
IMAGE_NAME, IMAGE_TAG |
Image name and tag. |
IMAGE_PLATFORM |
Build platform (e.g. linux/amd64). |
HOST_PORT |
Host port mapped to the container's 8080 (default 8080). |
LOG_LEVEL |
Runtime log level (default info). |
HTTP_SERVER_NAME |
Runtime server name (default LoudWebsite). |
SECURITY_STRICT_TRANSPORT_SECURITY |
HSTS header value (default max-age=31536000; includeSubDomains). |
DATABASE_DRIVER |
inMemory or postgres. The production Compose file defaults it to postgres; the local override defaults back to the in-memory backend. |
DATABASE_HOST, DATABASE_PASSWORD |
Mandatory — the production Compose file carries no default for either, since none can be correct: localhost inside the container is the container itself, and a blank password authenticates as nobody. It refuses to start without them rather than booting a website that serves 503s. Both come from .env, which is git-ignored — never commit the password. Compose interpolates each file before merging, so both must be set for a local docker compose up too, even though the override pins the host. |
DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME |
The rest of the PostgreSQL connection (when DATABASE_DRIVER=postgres); these do default (5432, loud, loud). |
DATABASE_POOL_MAX_PER_EVENT_LOOP |
Pooled connections per event loop (default 4) — see the connection budget 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.