Deploy the site
How the site deploys on Flightcontrol, how each deploy migrates the content model, and the environments and release flow.
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/revalidatewebhook. 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 ElastiCachefromServicebinding inflightcontrol.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_URLis 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/revalidatewebhook points at the environment, with thex-revalidate-secretheader (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/healthreports 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 touchingpackages/model/migrations/**,packages/model/apps/**orpackages/model/baseline/**) — clones an ephemeral environment fromdevelop(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: themodel-pr-gateconcurrency 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 todeveloptouchingpackages/model/migrations/**,packages/model/apps/**orpackages/model/src/dictionary/keys.ts, or by hand) — lints the migrations and the app registry, runsmigrate.mjs --env=develop(which syncs the Contentful apps after the migrations), opens a PR if the sync wrote a newpackages/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.mjsapplies 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 BOTHCI=trueandALLOW_PROTECTED_ENV=1. - Data steps. One-off scripts, dry by default (
--applywrites) 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-webhooksandsync-preview-urlsre-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 checkGET /api/health). A Docker build (buildType: "docker",Dockerfile.web), withincludeEnvVariablesInBuild: trueso the build sees every variable. Its service variables includeNODE_OPTIONS=--max-old-space-size=12288(the web build needs the memory),CACHE_REDIS_URLfrom the Redis service'sconnectionString(fromService), andBUILD_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:
node:24.22.1, pnpm 10.19.0 through corepack.NPM_TOKEN(a build argument) written to.npmrcfor the private@stadionhq-v3scope, thenpnpm install --frozen-lockfile --prod=false(the service setsNODE_ENV=production, which would otherwise skip the dev dependencies the build needs).RUN sh -c "${BUILD_COMMAND:-pnpm turbo run build --filter=@v4/web}", then the.npmrcis removed.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).