Files
ccn/Services/Website/README.md
T
javier 65b62681eb Project updates from Template (#1)
This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
2026-09-04 13:40:35 +00:00

43 KiB
Raw Blame History

CCN Website

The CCN public website service — a 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 and cached), plus one prefixed route per non-default catalog language — GET /nl and so on (see Language editions).
  • Negotiates each request's language from the lang query parameter, the leading path segment, then 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).
  • 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).
  • 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 tracker on both pages once a deployment configures one; it ships off, so an unconfigured copy requests no third-party script (see 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, 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 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)
    → HTTPSRedirectMiddleware            (301 to site.origin when forwarded over plain HTTP)
      → TrailingSlashRedirectMiddleware  (301 to the path without a trailing slash)
        → VaryMiddleware                 (marks every response as varying on Accept-Encoding)
          → ResponseCompressionMiddleware (gzip/deflate above the size threshold)
            → LocalizationMiddleware      (negotiates the language: ?lang=, path prefix, then Accept-Language)
              → NotFoundMiddleware        (renders the localized not-found page on .notFound)
                → FileMiddleware          (serves Resources/Static)
RootController                            (GET / → landing page; GET /<lang> → its other editions)
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 and optional. IndexPage supplies canonicalURL; the other three are 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. IndexPage derives it from site.origin and the page's own language; an unset origin omits it (see Language editions).
socialCard Open Graph + Twitter <meta> tags A SocialCard — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; Page+Defaults supplies the locale as ogLocale.
structuredData <script type="application/ld+json"> A StructuredData graph of schema.org nodes; StructuredData(name:url:logo:inLanguage:profiles:) builds the site-wide Organization + WebSite pair, inLanguage declaring the languages the site publishes in (see Language editions). 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 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 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 ogLocale a social card would carry, the preloadedFonts links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two theme-color metas. The last group lives in siteMetadata, which metadata returns unchanged — a page that adds head tags of its own composes siteMetadata rather than replacing it.

ogLocale renders Open Graph's language_TERRITORY form by looking the page's lang up in the ogLocales map, which ships one pairing: enen_US. A site serving a language in a territory of its own repoints or extends the map (en_NL, nl_NL, …); a language with no entry stays a bare code, which scrapers also accept. Nothing reads it until a page supplies a socialCard.

preloadedFonts is empty until the site ships fonts. Listing one emits <link rel="preload" as="font" crossorigin> at /font/<name>.woff2, deliberately unversioned: a preload URL must match the stylesheet's @font-face source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a unicode-range no page reaches would add a download that never otherwise happens.

Language editions

The WebsiteLibrary String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. Language (Sources/Library/Internal/Types) reads that list and derives each language's URLs from it.

The catalog's source language is the default and owns the site's bare paths; every other language answers under a prefix of its own. The root collapses onto the prefix alone, so a Dutch home is /nl, not /nl/ — the spelling TrailingSlashRedirectMiddleware redirects away from anyway:

Language Landing page A page at /privacy
en (default) / /privacy
nl /nl /nl/privacy

RootController registers the bare route plus one per non-default language. A prefixed route answers in its language for every visitor and every crawler — the path is the language choice, so the negotiated context language is ignored, which is what lets a search engine index it as that edition. The template ships an English-only catalog, so it registers the bare route alone.

Given a site.origin, each page then emits the hreflang alternates tying its editions together — one per language plus an x-default pointing at the default language's edition, whose bare URL negotiates the language and so is the right landing for everyone unmatched. A single-language site emits none: a set naming one edition tells a search engine nothing it cannot already see. Page+Defaults' languageAlternates(origin:path:languages:) takes the language set, so a page translated into only some of them narrows it rather than advertising an edition that does not exist.

The same set belongs in the site-wide structured data: StructuredData's inLanguage declares it on the WebSite node, so a crawler reads the site's languages from the graph as well as from the alternates. A page's social card says the same to a scraper: its locale is the edition the page is, and SocialCard's alternateLocales names the rest, each mapped through ogLocales the way ogLocale maps the page's.

sitemap.xml is the one part that does not follow the catalog: it is a static file, so a new language needs its editions added by hand — /nl, /nl/<page>, one <loc> each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.

Visitors switch language two ways, both handled by LocalizationMiddleware ahead of the routes: a ?lang= query parameter (what a language switcher links to) and the leading path segment. Either beats Accept-Language; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an unrouted path under a language's prefix answer its not-found page in that language.

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 the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked immutable. The pages reference them through content-versioned URLs (?v=<token>), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned.
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 the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be immutable.
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/*, video/mp4, image/jpeg, image/webp) resolve before the general text/* and image/* categories.

An image the markup references without a ?v= token must be neither JPEG nor WebP, or it is served immutable for a year and no deploy can dislodge it.

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 CCNWebsite 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 /about and /about/ 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.

Site

Config key Environment variable Default Description
site.origin SITE_ORIGIN (set by bootstrap) The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and the pages' rel="canonical" links and hreflang alternates derive from it.

Bootstrap writes the canonical URL it prompts for here, leaving it empty for the placeholder. An empty or non-HTTPS origin disables the HTTPS redirect, which https.trustForwardedProto must enable besides — a 301 is cached for a long time, so it is never issued at a host nobody named. An empty origin also leaves the pages without canonical URLs and language alternates, rather than building either against an empty host.

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). Any other value fails the boot.
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 ccn Database name. Ignored for inMemory.
database.username DATABASE_USERNAME ccn 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. Any other value fails the boot. 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.

Unrecognized tokens fail the boot. Neither database.driver nor database.tls falls back, because both fallbacks are silent and costly: an unrecognized driver would run on the ephemeral in-memory database and discard every write on restart, and an unrecognized posture would land on prefer, which hands the password over in plaintext when the upgrade is stripped. The thrown ConfigError names the tokens the key accepts.

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; 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.

Configured but unapplied. The template ships no endpoint worth limiting, so RateLimitMiddleware is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.

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 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 carries the same caveat: a cached 301 is as sticky as an HSTS commitment.

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 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), 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 (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:

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. 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 CCN.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:

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 ccn (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 (the root .dockerignore excludes **/Tests/DB 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

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:

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.

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:

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.

Each extension resolves to its own folder (css/, js/, img/, font/, video/), which a StaticFile overrides with in: when it needs one of its own — imagery conventionally sits in a folder per page (img/index). img/ ships empty. Every one of them is a constant of the StaticFile structure, 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 (every file in css/ and js/).
  • PNG images are losslessly recompressed with oxipng, recursively — the output is pixel-identical, only encoded smaller.
  • SVGs are minified with 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 constants 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

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 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:

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 CCNWebsite).
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, ccn, ccn).
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.