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>
43 KiB
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 /nland so on (see Language editions). - Negotiates each request's language from the
langquery parameter, the leading path segment, then itsAccept-Languageheader, against the languages in theWebsiteLibraryString Catalog, falling back to the default (en); pages are served from the per-language cache withContent-LanguageandVary: Accept-Languageheaders. - Builds every page on the shared
Pagescaffolding fromInfrastructure, which assembles the document head around the page's own markup: the viewport declaration, the optionaldescriptionsummary andrel="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 /healthwith a static JSON payload, and a readiness check atGET /health/readythat reports whether the database is reachable (200ready /503unavailable). - Answers
HEADon everyGETroute: the router is built with.autoGenerateHeadEndpoints, so uptime monitors and crawlers probing withHEADget the route's status and headers instead of a404. - Serves static files (CSS, JS, icons, manifest,
robots.txt,sitemap.xml) fromResources/Staticvia Hummingbird'sFileMiddleware, tagged with media-type-specificCache-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 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 (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— theLocalizeandNegotiatehelpers and theLanguageListof catalog languages (used byWebsiteLibrary).Infrastructure— the shared Hummingbird toolkit: theRouterControllerprotocol andaddControllerresult-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, thePageandAssetscaffolding, theSocialCard/StructuredData/Analyticshead-metadata types, the pre-rendered localized HTML responses, and theFingerprintAssetsversion-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the*+Defaultsextensions inWebsiteLibraryand theConfigReaderproperties in the executable.Persistence— the Fluent-based data layer: theDriverselector, theServicefactory that builds theFluentservice, thePrepareDBregistrar that declares the migrations, and theProbeconsulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency onswift-configuration; the executable maps thedatabase.*keys onto the driver.Utility— small shared helpers with no server dependencies, currently theNormalizeEmailmethod.
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: en → en_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:
- 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 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, not302— 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 theHostheader — 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.drivernordatabase.tlsfalls 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 onprefer, which hands the password over in plaintext when the upgrade is stripped. The thrownConfigErrornames the tokens the key accepts.
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; 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
RateLimitMiddlewareis 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:
- Check
String.Analytics.origin(Sources/Library/Public/Extensions/String+Constants.swift). It ships ashttps://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 whileanalytics.websiteIDis empty. - Extend
security.contentSecurityPolicyto allow that origin inscript-srcandconnect-src— the default policy is'self'-only, so the tracker is blocked until you do. - Set
ANALYTICS_WEBSITE_IDon 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.domainsto 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, clearanalytics.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-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. 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/andjs/). - 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.webmanifestships emptyname/short_namefields; 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-colorwhose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first inPage+Defaultsif 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.