Deploy the site

How the site deploys on Flightcontrol, how each deploy migrates the content model, and the environments and release flow.

2026-11-07

Overview

The Euro League website (apps/web) is a Next.js application that runs on Flightcontrol: Docker images on AWS Fargate, behind a load balancer, with a per-environment ElastiCache Redis that makes cache invalidation reach every replica. The environments, services and release flow are on This site's environments. Vercel hosts only static and documentation builds of this repo — Storybook from the root vercel.json, and the docs app from apps/docs/vercel.json (Vercel).

The app has its own build, its own Contentful space (12gd2cvfgb6b) and its own Redis; nothing is shared with any other site at runtime.

Declaring the target

The target is declared in apps/web/club.config.ts (defineClubConfig() from packages/template-config) under hosting:

// apps/web/club.config.ts
const redisUrl = process.env["CACHE_REDIS_URL"];
const hostingRedis: { redis?: EnvMarker } =
  redisUrl === undefined || redisUrl === "" ? {} : { redis: env.server("CACHE_REDIS_URL") };

hosting: {
  platform: "flightcontrol" as const, // "vercel" | "flightcontrol" | "node"
  ...hostingRedis,                     // declared only where the host provides a Redis
},

redis is declared only when CACHE_REDIS_URL is set: an unconditional env.server() marker would make the variable mandatory and fail every build with no Redis (CI, local dev).

platform defaults to node and redis is optional. The decision that changes caching behaviour is where the BUILD runs: on a self-hosted (non-Vercel) build, which is every build of this site, next.config.ts registers the shared-tags cache handler (use-cache-handler.mjs as cacheHandlers.default) and it resolves the Redis URL at runtime, degrading to a per-instance cache when there is none. (The gate is the build-time VERCEL env, NOT hosting.redis — the Redis URL is runtime-only, so gating on it skipped the handler at build; and a build on Vercel would use the platform's own shared cache instead.) The rest of this guide covers the Flightcontrol setup, the model deploy pipeline that migrates the content model alongside the app, and the environments.

What hosting does not change

These hold however the app is hosted:

  • Caching is declarative. Content reads register cache tags; a Contentful publish expires those tags through the /api/revalidate webhook. See the Caching & revalidation guide — none of that depends on the host. The Competition Engine's reads are cached and expired the same way, by its ingester's call to the same route.
  • Live data does not go through the cache. A live game's score and clock come from the Competition Engine's live endpoints, which are never cached on the server: the browser polls the app's /api/live/games. See Live data.
  • Listings are statically prerenderable because pagination is path-based (/en/news/page/2), so the high-traffic page-1 case is static.

Vercel

The website itself does not run on Vercel — it is on Flightcontrol (This site's environments). Vercel hosts two static/documentation builds of this repo.

Storybook — the root vercel.json

The root vercel.json builds Storybook (@v4/storybook, the design-system showcase), not the web app:

{
  "buildCommand": "pnpm turbo build --filter=@v4/storybook",
  "installCommand": "pnpm install --frozen-lockfile",
  "outputDirectory": "apps/storybook/storybook-static",
  "framework": null,
  "headers": [
    // long-lived immutable caching for /assets/** and the component preview
    // bundles; must-revalidate on /index.json (the story index)
  ],
}

The headers block sets Cache-Control: public, max-age=31536000, immutable on fingerprinted static assets and the component preview bundles, and max-age=0, must-revalidate on index.json (the story manifest, which changes every build).

The docs app — apps/docs/vercel.json

apps/docs/vercel.json builds this documentation app as a Next.js project ("framework": "nextjs"), installing only @v4/docs and its dependencies from the repo root (pnpm install --filter @v4/docs... --frozen-lockfile).

Both are also deployed as static sites on Flightcontrol's development environment.

If the web app were ever built on Vercel

next.config.ts decides the cache handler from where the build runs. On Vercel (process.env.VERCEL set) it does not register the shared-tags handler (apps/web/use-cache-handler.mjs): Vercel already shares one data cache across every serverless instance, so a webhook's revalidateTag reaches all of them, and wiring Redis on top would give the app two shared caches fighting over the same tags. Nothing else in the app changes — the revalidation webhook and live data work the same way.

Self-hosting with Flightcontrol

The site runs on AWS (eu-west-1) across one or more app replicas behind a load balancer. The infrastructure is provisioned through Flightcontrol (which also runs the model deploy pipeline, covered in the next section), the app runs as hosting.platform: "flightcontrol", and each environment has its own Redis so that cache invalidation fans out across every replica.

Why Redis is needed

revalidateTag() only invalidates the cache of the instance that runs it. Across N replicas, a webhook lands on one instance; the others would keep serving stale content. The shared-tags cache handler (apps/web/use-cache-handler.mjs) solves that by sharing the TAG manifest — each tag's stale and expired stamps — in a Redis every replica reads. The cache ENTRIES stay per-instance (Next's built-in handler, 50 MB); what travels is the fact that a tag was revalidated. updateTags records a revalidation locally and shares it; refreshTags merges the shared manifest into the local one before reads, so one webhook's revalidateTag reaches every replica.

The mechanics are documented in full in Self-hosting the shared-tags cache handler. This section is about deploying it, not re-explaining it.

Configuration

The platform is set, and hosting.redis is declared only when CACHE_REDIS_URL is set, so builds without a Redis stay valid — see Overview:

// apps/web/club.config.ts
hosting: {
  platform: "flightcontrol" as const,
  ...hostingRedis, // { redis: env.server("CACHE_REDIS_URL") } when it is set
},

Because the app runs cacheComponents, next.config.ts registers apps/web/use-cache-handler.mjs as cacheHandlers.default — the new handler API use cache actually consults (the legacy singular cacheHandler is ignored under cacheComponents). The registration is decided at BUILD and gates on a build-stable signal — the build is not on Vercel — because CACHE_REDIS_URL here comes from the ElastiCache service via a runtime fromService binding that is ABSENT at build. The handler resolves that URL at RUNTIME.

  • CACHE_REDIS_URL — the Redis connection URL, injected at runtime by the ElastiCache fromService binding in flightcontrol.json.
  • Entry STORAGE stays per-instance (Next's built-in handler, 50 MB); only the tag invalidation manifest is shared across replicas.

With no CACHE_REDIS_URL at runtime the handler degrades to the built-in per-instance cache — the correct choice for a single self-hosted replica too (there are no other instances to fan out to).

Deployment Handler wired? Cache
No Redis (local, CI) yes built-in per-instance (handler no-ops)
N replicas with Redis yes shared tags manifest via Redis

Each environment has its own Redis — a hard rule

The site points at its own Redis, one per environment. Never point it at a Redis another site or environment uses: one environment's invalidation, or worse its cached page, must never reach another. The separate Redis IS the isolation boundary, so the shared handler writes a single fixed key (no prefix — a prefix could not be sourced consistently across Next's bundled vs raw-module realms):

usecache:tagman   ← Redis HASH: field = tag → { stale, expired }

It fails safe

Every Redis operation in the handler is wrapped so any error — a connection blip, a timeout, malformed stored JSON — degrades to local, per-instance behaviour (the replica's own tag manifest), never a request failure. After a failed connection the handler waits 30 seconds (RECONNECT_COOLDOWN_MS) before trying Redis again, so an outage is not hammered. A Redis outage slows the app down; it never takes it down. Operationally that means a Redis incident is a performance event, not an availability one, but it is still worth alerting on because a sustained outage removes the multi-instance invalidation guarantee (replicas fall back to their own in-memory caches and can drift until TTL).

What to verify on a self-hosted deploy

  • CACHE_REDIS_URL is set in the environment and reachable from every replica, not just one — and this Redis serves this environment ONLY.
  • This Redis is not shared with anything else (the fixed manifest key means the Redis instance is the whole isolation boundary).
  • The /api/revalidate webhook points at the environment, with the x-revalidate-secret header (CONTENTFUL_WEBHOOK_SECRET) — the handler changes how invalidation fans out, not how it arrives. Without the webhook, pages only self-heal at their TTL floor.
  • GET /api/health reports which cache handler the build wired.

The model deploy pipeline

The content model (@v4/model) is model-as-code: its source of truth is the migrations/ directory, not the Contentful web app. Deploying an app therefore means deploying a model alongside it, and the two are kept in lockstep by a strict division of responsibility:

Actions VALIDATE. The deploy MUTATES.

GitHub Actions never touch a production-facing environment. The deploy is the only thing that migrates the environments real users read from.

What GitHub Actions do — validate, and mutate only develop

Two workflows run in CI, and neither is the deploy:

  • model-pr-gate.yml (on a PR touching packages/model/migrations/**, packages/model/apps/** or packages/model/baseline/**) — clones an ephemeral environment from develop (model-gate-pr<N>), applies the pending migrations to it, re-introspects the schema, regenerates the types, and fails on drift. So a model change and its generated artefacts (schema.graphql, src/__generated__) land together in one reviewed PR, proven against a real Contentful environment. One ephemeral slot is shared: the model-pr-gate concurrency group serialises PRs through it. The environment is deleted afterwards. It is not a required check — do not merge a model PR with it red.
  • model-apply-develop.yml (on a push to develop touching packages/model/migrations/**, packages/model/apps/** or packages/model/src/dictionary/keys.ts, or by hand) — lints the migrations and the app registry, runs migrate.mjs --env=develop (which syncs the Contentful apps after the migrations), opens a PR if the sync wrote a new packages/model/apps/apps.lock.json, seeds any new dictionary keys (sync-dictionary.mjs push) and announces what reached develop. This is the only environment Actions mutate, and it is not production-facing.

Everything CI does is a gate or a develop-only apply. It never migrates master, production, staging, or staging-release.

What the deploy does — mutate production-facing environments

Production-facing environments are migrated by the deploy itself. On this site's Flightcontrol stack the web service is a Docker build, and the migration runs inside its BUILD_COMMAND (the env var Dockerfile.web runs), BEFORE the app build, so the build bakes against the migrated model. Staging's, one step per line:

CI=true ALLOW_PROTECTED_ENV=1 node packages/model/scripts/migrate.mjs --env=staging --until=club/0059-game-team-sides \
&& CI=true ALLOW_PROTECTED_ENV=1 node packages/model/scripts/backfill-article-competition.mjs --env=staging --apply \
&& CI=true ALLOW_PROTECTED_ENV=1 node packages/model/scripts/merge-players-into-profile.mjs --env=staging --apply \
&& pnpm turbo run build --filter=@v4/web \
&& (node packages/model/scripts/sync-webhooks.mjs; node packages/model/scripts/sync-preview-urls.mjs; true)

Each step earns its place:

  • Migrate. migrate.mjs applies exactly the pending migrations for the environment, tracked by its ledger (modelMigrationState), then syncs the Contentful apps (unless --skip-apps). --until=<migration> applies up to and including that migration and holds the rest back — so a data step that must run BETWEEN two migrations (here, giving the seeded articles a competition and merging the players into the profile type) runs at the right point. The value is moved forward as each release's data steps are done. Staging is a protected environment, so every model command carries BOTH CI=true and ALLOW_PROTECTED_ENV=1.
  • Data steps. One-off scripts, dry by default (--apply writes) and refused on protected environments outside CI like the runner. They must be safe to re-run, because every staging deploy runs them again.
  • Build. The app is built against the just-migrated model. A failure in any earlier step fails the build, and the running app keeps serving.
  • Provision. sync-webhooks and sync-preview-urls re-assert the environment's revalidation webhook and preview URLs, non-fatally.

Development's BUILD_COMMAND runs no migration — develop is migrated by model-apply-develop.yml on merge — only the build and the provisioning.

Planned, not implemented: a model EXPORT to a pipeline artifact before the migration, as a recovery snapshot. No step in flightcontrol.json does it today.

Why the ordering is safe — expand-first migrations

If the migration fails, the build fails, and the old app keeps serving the old model — no traffic switch happens. There is a residual window between the migration and the new app going live where the old app is running against the new model, and that window is safe by construction because every migration is expand-first: additive, backwards-compatible changes ship first; destructive ones (deleteField, changeFieldId, required(true) on an existing field, deleteContentType) ship one release after the code that stopped depending on the thing, carrying a // contract: marker. The old app never sees a breaking model change during a deploy.

Protected environments are refused outside CI

The migration runner refuses to run against master, production, staging, or staging-release unless BOTH CI=true and ALLOW_PROTECTED_ENV=1 are set, and --env is always explicit — there is no default that could accidentally point at production. Never weaken that guard, and never set those variables on a laptop.

Rollback

Forward-fix is the norm — ship a corrective expand migration through the same pipeline. For emergencies, repoint the environment alias to the previous known- good environment. There are no down() migrations by design.

For the model's authoring workflow (how migrations are written, linted for expand/contract, and drift-checked locally), see Change the content model, the content-model architecture docs and .claude/rules/model/contentful-model.md.

This site's environments

Euro League's deployments are declared in the root flightcontrol.json, region eu-west-1. There is no production environment in it yet.

Flightcontrol environment Deploys from Contentful environment Services
development a push to develop develop Redis, web, docs (static), Storybook (static)
staging a push to the current release branch (release/x.y.z, named in flightcontrol.json) staging (protected) Redis, web

Moving staging to the next release means changing its source.branch in flightcontrol.json.

The services

  • Redis (type: "elasticache", Redis 7.1, cache.t4g.micro, no replicas, evictionPolicy: "allkeys-lru", encrypted at rest) — this environment's own, holding only the shared tag manifest (Self-hosting with Flightcontrol).
  • Web (type: "web", Fargate, 1 vCPU / 2 GB, 1–2 instances autoscaled on CPU, memory and requests, port 3000, health check GET /api/health). A Docker build (buildType: "docker", Dockerfile.web), with includeEnvVariablesInBuild: true so the build sees every variable. Its service variables include NODE_OPTIONS=--max-old-space-size=12288 (the web build needs the memory), CACHE_REDIS_URL from the Redis service's connectionString (fromService), and BUILD_COMMAND.
  • Docs and Storybook (development only) — static sites built with Nixpacks (pnpm turbo run build --filter=@v4/docs, --filter=@v4/storybook).

/api/health answers from the process alone — it never calls Contentful, Redis or the video platform — and reports which cache handler the build wired, so one curl after a first deploy shows whether invalidation will reach every replica.

Dockerfile.web

The web image replaces Nixpacks, which can pin only the Node major:

  1. node:24.22.1, pnpm 10.19.0 through corepack.
  2. NPM_TOKEN (a build argument) written to .npmrc for the private @stadionhq-v3 scope, then pnpm install --frozen-lockfile --prod=false (the service sets NODE_ENV=production, which would otherwise skip the dev dependencies the build needs).
  3. RUN sh -c "${BUILD_COMMAND:-pnpm turbo run build --filter=@v4/web}", then the .npmrc is removed.
  4. CMD pnpm --filter @v4/web start.

BUILD_COMMAND is what differs per environment:

Environment BUILD_COMMAND
development build, then sync-webhooks and sync-preview-urls (non-fatal)
staging migrate.mjs --env=staging --until=…, the release's data steps, build, then sync-webhooks and sync-preview-urls (non-fatal)

Develop's model is applied by model-apply-develop.yml on merge, so its build migrates nothing; staging's model moves with its deploy (The model deploy pipeline).

Releases

develop is integration. A release is a release/x.y.z branch: every push to it is a staging build, which release-build.yml tags vx.y.z-rc.N, publishes as a GitHub pre-release, and reports to Jira (the Fix Version on the tickets the build contains). release.yml, dispatched by hand, fast-forwards main to the release and tags it. deploy-watch.yml listens to Flightcontrol's deployment statuses, because CI deliberately does not build @v4/web (its production build needs a Contentful delivery token) and a broken web build otherwise surfaces only at deploy.

The Contentful apps

The Contentful apps (the custom field editors in the space) are built and released from the Publishing Hub template repo; this repo only installs them. migrate.mjs runs sync-apps after the migrations, which installs each app the environment's release channel hosts: develop-class environments take the (v4 develop) definitions, the protected ones (staging) the (v4) production definitions. Until a production definition has a released bundle, staging shows none of the apps and keeps the widgets it had (.claude/rules/model/contentful-apps.md).