Files
ccn/Services/Website

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-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).
  • 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 for make site-run (watch and rebuild on change).

Architecture

Two SwiftPM targets:

Target Kind Path Role
Website executable Sources/App Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode.
WebsiteLibrary library Sources/Library Controllers, the pages and their shared chrome fragments, the StaticFile asset catalog, the request context, the subscription form model, and the *+Defaults extensions and configuration-key constants that supply the site's specifics to Infrastructure.

The Website executable depends on four local packages, each under Packages/:

  • Localization — the Localize and Negotiate helpers and the LanguageList of catalog languages (used by WebsiteLibrary).
  • Infrastructure — the shared Hummingbird toolkit: declarative routing (RouterController, addController), the security/vary/rate-limit/localization/not-found middlewares, the Page and Asset scaffolding, the SocialCard/StructuredData/Analytics metadata types, the pre-rendered localized HTML responses, and FingerprintAssets. The service supplies its own specifics (String Catalog, pages, icons, analytics) through the *+Defaults extensions and ConfigReader properties in WebsiteLibrary and the executable.
  • Persistence — the Fluent data layer: the Driver selector, the Service factory, the PrepareDB migration registrar, and the Probe behind the readiness check. Subscriber and the repository types are public; record models and migrations stay internal. It has no swift-configuration dependency — the executable maps the database.* keys onto the driver.
  • Utility — shared helpers with no server dependencies, such as the NormalizeEmail the subscription flow canonicalizes addresses with.

The persistence backend runs as a Fluent service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).

Requests pass through the middleware chain in this order (outermost first), then reach the routes:

LogRequestsMiddleware
  → SecurityHeadersMiddleware        (security headers on every response)
    → VaryMiddleware                 (marks every response as varying on Accept-Encoding)
      → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
        → LocalizationMiddleware      (negotiates the request's language)
          → NotFoundMiddleware        (renders the localized not-found page on .notFound)
            → FileMiddleware          (serves Resources/Static)
RootController                        (GET / → landing page)
SubscriptionController                (POST /subscribe → newsletter subscription)
HealthController                      (GET /health → liveness, GET /health/ready → readiness)

Pages

Both pages are rendered with Elementary and share the site chrome — the sticky top bar, the mobile drawer, and the footer — through the Chrome fragment, styled by /css/shared.css and driven by /js/shared.js. Every script is a progressive enhancement, loaded 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 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, 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 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.serverNameHTTP_SERVER_NAME, security.strictTransportSecuritySECURITY_STRICT_TRANSPORT_SECURITY, cache.maxAge.textCACHE_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.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 below for the workflow.

Paths

Config key Environment variable Default Description
path.staticFiles PATH_STATIC_FILES Resources/Static Directory, relative to the working directory, that static files are served from.

Rate limiting

Config key Environment variable Default Description
rateLimit.limit RATELIMIT_LIMIT 5 Requests admitted per client per window on the subscribe endpoint; the excess is answered with 429 Too Many Requests and a Retry-After header.
rateLimit.window RATELIMIT_WINDOW 60 Window length, in seconds, the limit applies to.
rateLimit.trustForwardedFor RATELIMIT_TRUST_FORWARDED_FOR false Key clients by the first X-Forwarded-For entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it.

Site

Config key Environment variable Default Description
site.origin SITE_ORIGIN https://loud.amsterdam Public origin the site is served at (scheme and host, no trailing slash). The pages derive their canonical URL and other absolute links (social card image, structured data) from it, so a staging deployment can point it at itself instead of leaking the production origin into its markup.

Analytics

Config key Environment variable Default Description
analytics.websiteID ANALYTICS_WEBSITE_ID f28681d6-20e8-43f3-9c3b-5d6a0f8e0591 The analytics website identifier the tracker on both pages reports as. Set it to an empty string to disable analytics entirely — the tracker script is then omitted from the pages.
analytics.domains ANALYTICS_DOMAINS loud.amsterdam Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored.
analytics.recorder ANALYTICS_RECORDER true Whether the pages also embed the session recorder script (recorder.js, loaded from the tracker's origin) alongside the tracker. Set it to false to disable session recording on a deployment.

The tracker's origin (https://analytics.rock-n-code.com) is not configurable: it is single-sourced in code so the tracker tag and the Content-Security-Policy that must allow it (security.contentSecurityPolicy below) always agree. The pages also emit a preconnect hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.

Keep analytics.domains in sync with site.origin. Both encode the deployment's public host — the hosts the tracker reports from, and the host the pages are served at. Override one without the other (say, pointing a staging deployment at itself) and the domain filter stops matching: every visit is dropped silently, with no error. To disable analytics on a deployment instead, clear analytics.websiteID (see above).

Security headers

Config key Environment variable Default
security.contentSecurityPolicy SECURITY_CONTENT_SECURITY_POLICY default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
security.contentTypeOptions SECURITY_CONTENT_TYPE_OPTIONS nosniff
security.frameOptions SECURITY_FRAME_OPTIONS DENY
security.referrerPolicy SECURITY_REFERRER_POLICY strict-origin-when-cross-origin
security.permissionsPolicy SECURITY_PERMISSIONS_POLICY accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
security.strictTransportSecurity SECURITY_STRICT_TRANSPORT_SECURITY none (omitted)

Strict-Transport-Security has no default and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. docker-compose.yml enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.

Running locally

Directly with Swift:

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-mount is silently discarded (see 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

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 moov index atom leads the file (faststart) and playback can start before the download finishes; streams are copied, not re-encoded.

Files keep their names and paths, so the URLs derived from the StaticFile enumeration are unaffected. The repository sources stay readable and unminified: a direct swift run serves them as-is, while any image build — including the local make site-mount one — serves the optimized copies. Assets are copied from the assets stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.

Preview the optimized output locally (Docker only, writes to the git-ignored .build/minified):

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.