Deploying xtandard-image
Ready-to-use files for every common platform and infrastructure-as-code tool, each with autoscaling
set up (minimum and maximum instances, the scaling signal and its target, cooldowns). The files live
in deploy/. Sizing (OPERATIONS.md §2), the theory
behind the scaling signals and how each platform expresses them
(§3.7), CDN recipes
(§4) and observability (§6)
are in OPERATIONS.md; Kubernetes (Kustomize, the Helm chart, HPA and
KEDA) is in deploy/kubernetes and deploy/helm and is documented there too. Every variable used
below is in CONFIG.md.
Nothing here has been deployed by the project: each file was validated offline by its tool (and the
Docker ones were run locally), as the matrix says. The images are a private GHCR
package for now, so every platform needs registry credentials (a GitHub token with read:packages)
or a copy of the image in its own registry.
Contents
Section titled “Contents”- What every deployment needs
- Image and video fleets
- Platform matrix
- Containers: docker run · Docker Compose · Docker Swarm · Nomad · Kubernetes
- Self-hosted PaaS: Dokploy · Coolify · Openship
- Managed: Fly.io · Google Cloud Run · AWS ECS on Fargate · AWS App Runner · Azure Container Apps · Railway · Render · DigitalOcean App Platform · Modal
- Infrastructure as code: Terraform / OpenTofu · Pulumi · SST · AWS CDK
- Verification · Licences
What every deployment needs
Section titled “What every deployment needs”| Concern | What to do |
|---|---|
| Image | ghcr.io/xantiagoma/xtandard-image:<version>-slim for stills; -full for video, detection and LGPL FFmpeg; -gpl for H.264/HEVC output (GPL, read the README’s licence note first). Pin a version, never latest. Multi-arch (amd64, arm64). |
| Secrets | XI_SIGNING_KEYS and XI_SIGNING_SALTS (hex, openssl rand -hex 32 each; comma-separated lists rotate) from the platform’s secret store, never in the file. Any variable also takes a _FILE form for mounted secrets. A video fleet also needs XI_VIDEO_TRANSFORM_STREAM_KEYS, the same on every instance, or the HLS/DASH child URLs of one instance fail on another. |
| Sources | The allowlist of origins: XI_SOURCES=web, XI_SOURCES_WEB_TYPE=http, XI_SOURCES_WEB_ALLOW=<hosts> in these files; S3, GCS, Azure and the rest in SOURCES.md. On a cloud, prefer the platform identity (task role, service account, managed identity) over keys. |
| Filesystem | Read-only root filesystem and a writable /tmp (64 MiB is enough for images: fontconfig cache only). RELEASE.md “Running read-only”. |
| Health | Liveness /healthz (no libvips call), readiness and load-balancer health /readyz (503 while starting or draining). The image’s HEALTHCHECK runs xtandard-image health (GET /healthz; no curl in the image). |
| Shutdown | SIGTERM → /readyz 503 → XI_SERVER_SHUTDOWN_DELAY (5 s) for load balancers to notice → drain for up to XI_SERVER_SHUTDOWN_TIMEOUT (30 s). Give the platform a grace period above the sum (40 s in these files); on platforms with a shorter fixed grace (Cloud Run: 10 s) shorten both. |
| CPU and memory | Always set limits. Workers follow the CPU limit (GOMAXPROCS, XI_PROCESSING_WORKERS defaults to it) and GOMEMLIMIT is 30 % of the memory limit. 2 vCPU / 2–4 GiB is a good image instance; video wants 4 vCPU / 8 GiB or more. OPERATIONS.md §2 has the sizing. |
| Admin listener | /metrics, pprof and the docs are on XI_ADMIN_ADDR (default 127.0.0.1:9090, loopback only). Set :9090 only where the port stays on a private network (compose network, Fly private network, an internal port); never route it publicly. Metrics, dashboards and alerts: OPERATIONS.md §6. |
| CDN | Put one in front: results carry long Cache-Control and an ETag, and the cache key must include Accept when the format follows it. Recipes (Cloudflare, CloudFront, Fastly, Bunny) in OPERATIONS.md §4. |
Picking the scaling signal
Section titled “Picking the scaling signal”The server bounds its own work: at most XI_PROCESSING_WORKERS images at once (one per vCPU),
XI_PROCESSING_QUEUE_SIZE waiting (workers × 16), then 429 with Retry-After. A platform should
add instances before the queue fills:
- Requests in flight per instance (Cloud Run
containerConcurrency, Flysoft_limit, Container AppsconcurrentRequests, App RunnerMaxConcurrency) is the best signal the platforms offer: about 2–4 × the vCPU count for images (8–16 for 2 vCPU), 2–4 for video. - CPU utilization (ECS, Render, DigitalOcean, Container Apps custom rule) works because busy workers are busy CPUs: target 60–70 %.
- The server’s own metrics (
xi_workers_busy,xi_queue_depth,xi_overload_total) are the most precise and need a metrics pipeline: KEDA or a Prometheus adapter on Kubernetes (OPERATIONS.md §3.7), the Nomad Autoscaler. - Scale in slowly (5 minute cooldowns or stabilization windows here) and out quickly (1 minute).
Image and video fleets
Section titled “Image and video fleets”Video transformations (XI_VIDEO_TRANSFORM_ENABLED, full and gpl images) take minutes of CPU and
gigabytes of spool space, so run them on their own instances: XI_ROLE=image instances answer 404
to video, XI_ROLE=video instances answer 404 to everything but video (plus health and admin), and
the router sends these paths to the video fleet:
| Path | What |
|---|---|
/video/… |
imgproxy dialect video transformations and their HLS/DASH manifests (URL-DIALECTS §3.8) |
/q/video |
query dialect video transformations (GET and QUERY) |
/_stream/… |
child URLs of manifests: playlists, init and media segments (under XI_SERVER_PATH_PREFIX) |
/<cloud>/video/upload/…, /<cloud>/video/fetch/… |
Cloudinary video, if that dialect is mounted (it also serves stills of a video; the video fleet answers those too) |
/cdn-cgi/media/… |
Cloudflare Media Transformations, if mounted |
A video instance also needs a disk-backed spool (XI_VIDEO_TRANSFORM_SPOOL_DIR, writable by UID
65532, with XI_VIDEO_TRANSFORM_SPOOL_MAX_BYTES below its size): a tmpfs is RAM, which is what the
spool saves. Video results belong in the disk or S3 result cache tier, not memory (ARCHITECTURE
§11.1). Its drain is longer: XI_VIDEO_TRANSFORM_TIMEOUT is 2 minutes, so the files here give the
video fleet XI_SERVER_SHUTDOWN_TIMEOUT=130s and a 150 s grace period where the platform allows it.
deploy/compose/compose.split.yml with
nginx.conf is the reference router; the same table goes into an
ALB listener rule, a GCP URL map, a DigitalOcean ingress rule or a CDN origin rule. With one fleet
(XI_ROLE=all, the default) nothing needs routing.
Platform matrix
Section titled “Platform matrix”“Run locally” means started and exercised with xtandard-image:main-gpl on Docker; “validated”
means the file passed its tool or schema offline and the server accepted its environment
(xtandard-image config validate), see Verification; “not verified on the
platform” means nobody deployed it.
| Platform | File | Scales on | Min / max knobs | Scale to zero | Disk for the video spool | Admin / metrics | Verified |
|---|---|---|---|---|---|---|---|
| docker run | deploy/docker/run.sh |
— (one container) | — | no | -v volume |
loopback in the container | shellcheck; same flags run locally by the compose run |
| Docker Compose | deploy/compose/ |
manual --scale |
--scale svc=N |
no | per-replica anonymous volume | :9090 on the compose network, not published |
run locally (single, split + nginx routing) |
| Docker Swarm | deploy/swarm/stack.yml |
manual (no autoscaler) | replicas, docker service scale |
no | volume per node | overlay network xi |
run locally (throwaway swarm: 2/2 replicas, update and rollback config) |
| Nomad | deploy/nomad/ |
Nomad Autoscaler on xi_workers_busy |
scaling { min max } |
no | host volume / CSI | admin port, service xtandard-image-metrics |
validated (nomad job validate) |
| Kubernetes | deploy/kubernetes, deploy/helm (WP-148) |
HPA (CPU) or KEDA (server metrics) | chart values | with KEDA | emptyDir without medium |
ClusterIP / pod annotations | validated by WP-148 (task deploy:check, OPERATIONS.md §8) |
| Dokploy | deploy/dokploy/compose.yml |
manual (Swarm replicas) | deploy.replicas / Cluster Settings |
no | volume | dokploy-network |
validated (docker stack config, config validate); not verified on the platform |
| Coolify | deploy/coolify/compose.yml |
none (one container per service) | — | no | named volume | compose network | validated (docker compose config, config validate); not verified on the platform |
| Openship | deploy/openship/docker-compose.yml |
unknown (see below) | unknown | unknown | volume | compose network | validated (docker compose config); not verified: platform behaviour undocumented |
| Fly.io | deploy/fly/fly.toml, fly.video.toml |
requests per Machine (soft_limit) via autostop/autostart |
min_machines_running; max = Machines created (fly scale count) |
yes (min_machines_running = 0) |
Fly volume (one per Machine) | [[metrics]] on the private network |
validated (SchemaStore fly.json, config validate); not verified on the platform |
| Google Cloud Run | deploy/cloud-run/ |
concurrent requests and CPU | minScale, maxScale annotations |
yes (minScale: "0") |
none (in-memory volume counts against memory) | loopback: a sidecar scrapes it | validated (kubeconform Knative v1, config validate); not verified on the platform |
| AWS ECS on Fargate | deploy/aws-ecs/ |
target tracking: CPU and/or ALB requests per target | scalable target MinCapacity, MaxCapacity |
no | task ephemeral storage (21–200 GiB) | loopback, or a sidecar | validated (botocore models, config validate); not verified on the platform |
| AWS App Runner | deploy/aws-app-runner/ |
concurrent requests (MaxConcurrency) |
MinSize (≥ 1), MaxSize |
no | none (3 GB ephemeral) | not reachable | validated (botocore); closed to new customers |
| Azure Container Apps | deploy/azure-container-apps/containerapp.yaml |
KEDA http (concurrent requests) + cpu rule | minReplicas, maxReplicas (≤ 1000) |
yes (min 0, http rule only) | EmptyDir (replica disk) |
loopback, or additionalPortMappings (internal) |
validated (Azure REST spec 2026-01-01, config validate); not verified on the platform |
| Railway | deploy/railway/railway.json |
none horizontally (vertical up to plan limits) | numReplicas |
app sleeping (sleepApplication) |
one volume, not with replicas | private network *.railway.internal |
validated (railway.schema.json); not verified on the platform |
| Render | deploy/render/render.yaml |
CPU and memory targets | scaling.minInstances (≥ 1), maxInstances |
no | a disk blocks scaling | private network | validated (render.yaml.json, config validate); not verified on the platform |
| DigitalOcean App Platform | deploy/digitalocean/app.yaml |
CPU (dedicated sizes), requests per second, p95 latency | autoscaling.min/max_instance_count |
no | none | internal_ports |
validated (DigitalOcean OpenAPI app_spec, config validate); not verified on the platform |
| Modal | deploy/modal/app.py (both fleets) |
requests in flight per container (@modal.concurrent target_inputs) |
min_containers, max_containers, buffer_containers |
yes (min_containers=0) |
container ephemeral disk (ephemeral_disk, lost when the container stops) |
not reachable (only the web server port is exposed); push OTel | validated (loads with modal 1.6.2, config validate); server lifecycle run locally in a Modal-like container; not verified on the platform |
| Terraform / OpenTofu | deploy/terraform/ |
as the platform | variables min_*, max_* |
as the platform | — | — | validated (tofu/terraform validate, tflint) |
| Pulumi | deploy/pulumi/ |
ECS CPU target tracking; Cloud Run; Helm | config minInstances, maxInstances |
— | — | — | validated (tsc --noEmit) |
| SST | deploy/sst/ |
CPU, memory, ALB requests | scaling.min, scaling.max |
no | — | — | validated (sst install + tsc --noEmit) |
| AWS CDK | deploy/cdk/ |
CPU and ALB requests | autoScaleTaskCount |
no | — | — | validated (tsc --noEmit, cdk synth without credentials) |
Containers
Section titled “Containers”docker run
Section titled “docker run”deploy/docker/run.sh starts one container with the production flags:
--read-only, a 64 MiB /tmp tmpfs, --cap-drop ALL, no-new-privileges, --cpus 2 --memory 2g
(workers and GOMEMLIMIT follow them), --stop-timeout 40, the signing keys from your environment
and the http source. One container does not scale; use Compose or Swarm for replicas.
Docker Compose
Section titled “Docker Compose”compose.yml: one service,.envfrom.env.example.docker compose up -d --scale xtandard-image=3starts replicas, but only one can publish the host port: put a proxy in front, as below.compose.split.yml: the image fleet (XI_ROLE=image, slim), the video fleet (XI_ROLE=video, full, one anonymous disk volume per replica at/var/tmpfor the spool and the disk cache) and nginx routing the video paths. nginx re-resolves the service names every 10 s, sodocker compose up -d --scale image=4 --scale video=2works without touching the proxy. nginx addsX-Xi-Fleetto responses (which fleet answered); drop that line in production if you prefer.
Compose has no autoscaler. stop_grace_period: 40s, read_only, tmpfs, cap_drop and the CPU
and memory limits are set; the image’s own HEALTHCHECK is used.
Docker Swarm
Section titled “Docker Swarm”deploy/swarm/stack.yml: replicas: 2, CPU/memory limits and
reservations, max_replicas_per_node, rolling updates start-first one at a time with
failure_action: rollback after a 30 s monitor window, rollback_config, restart_policy, an
attachable overlay network for Prometheus to reach :9090, and secrets through _FILE variables
(commented). Stack-file details checked against the docker/cli converter: the service-level
tmpfs: key is ignored by docker stack deploy, so /tmp is a type: tmpfs volume; security_opt
and cap_drop are not applied by stack deploy either.
Swarm has no autoscaler. Options: scale by hand or from a cron job/CI on a metric
(docker service scale xi_xtandard-image=N driven by xi_workers_busy or CPU from Prometheus), or
a third-party autoscaler (not evaluated here). If you need autoscaling, use a platform below or
Kubernetes.
deploy/nomad/xtandard-image.nomad.hcl: Docker driver
with readonly_rootfs, a tmpfs /tmp, cap_drop = ["ALL"], cores = 2 (workers follow the
cpuset) and memory_max, secrets from Nomad Variables through a template, readiness check on
/readyz, shutdown_delay = "5s" (deregister before SIGTERM) and kill_timeout = "40s", rolling
updates with auto_revert. The scaling block sets min 2 / max 20 and a
Nomad Autoscaler policy (target-value on
avg(xi_workers_busy) from Prometheus, 2 minute cooldown); without the autoscaler agent, count
is fixed. The metrics port is a second Nomad service for Prometheus’ Nomad service discovery.
Kubernetes
Section titled “Kubernetes”Owned by WP-148: deploy/kubernetes (Kustomize) and deploy/helm/xtandard-image (the chart, with
role, HPA or KEDA autoscaling and min/max replicas), documented in OPERATIONS.md §3.
CONFIG.md §6 has a minimal manifest. The Terraform and Pulumi examples below
install that chart.
Self-hosted PaaS
Section titled “Self-hosted PaaS”Dokploy
Section titled “Dokploy”Dokploy runs applications on Docker Swarm. Two ways:
- Application: image
ghcr.io/xantiagoma/xtandard-image:<version>-slim, port 8080, the variables of the file below in Environment, a domain in Domains. Replicas are under Advanced → Cluster Settings; the Swarm Settings JSON editors take the sameUpdateConfig,RollbackConfig,RestartPolicyand health check as the stack file. Redeploy after changing them. - Compose, type “Stack”: paste
deploy/dokploy/compose.yml(Swarm stack syntax, Traefik labels underdeploy.labels, the externaldokploy-network).
Dokploy does not autoscale: replicas are fixed. CONFIG.md §4 has the minimal Application setup.
Coolify
Section titled “Coolify”deploy/coolify/compose.yml for the Docker Compose build pack:
SERVICE_FQDN_XTANDARD_8080 makes Coolify generate a domain routed to port 8080, the secrets come from
the Environment Variables tab, and a named volume holds a disk result cache. Coolify v4 runs one
container per service: no replicas and no autoscaling (its Swarm mode is deprecated and will be
removed in v5). Scale by running the same service on several servers behind a load balancer, or
use another platform. CONFIG.md §5 has a variant.
Openship
Section titled “Openship”Openship (oblien/openship,
Apache-2.0) is a young self-hosted PaaS that deploys an existing docker-compose.yml as is (its
Compose mode on a Linux server with Docker, or bare mode over SSH) behind its own OpenResty edge.
Its README claims autoscaling on its cloud offering and multi-node self-hosting, but its
documentation did not say (at the time of writing) how prebuilt images, replicas, health checks or
secrets are configured. deploy/openship/docker-compose.yml
is therefore the plain compose service; set the secrets in Openship’s environment settings and route
the domain to port 8080. Not verified on the platform; autoscaling behaviour unknown.
Managed platforms
Section titled “Managed platforms”Fly.io
Section titled “Fly.io”deploy/fly/fly.toml (image fleet) and
fly.video.toml (video fleet, a separate app with a volume).
- How it scales: Fly never creates Machines on demand.
fly scale count Ncreates the maximum; the proxy stops idle Machines (auto_stop_machines = "stop", or"suspend"for a faster resume) and starts stopped ones when the running ones passsoft_limit(auto_start_machines = true).min_machines_running(2 here, primary region only) never stop;0scales to zero. - Concurrency:
type = "requests",soft_limit = 8(2 × workers onperformance-2x),hard_limit = 64(the proxy stops sending more; the server’s own queue still answers 429 past workers × 16). The video app uses 4 / 16. - Size:
[[vm]] size = "performance-2x",memory = "4gb"; workers follow the vCPUs. - Health:
[[http_service.checks]]GET/readyz(10 s grace). - Shutdown:
kill_signal = "SIGTERM",kill_timeout = 40(Fly’s maximum is 300; video 150). - Metrics:
XI_ADMIN_ADDR=":9090"with[[metrics]] port = 9090: Fly scrapes it over the private network; the port is not in[http_service], so it is not public. - Video:
[mounts]gives each Machine its own volume at/datafor the spool and the disk cache. Fly creates the volume root-owned; the image runs as UID 65532, so create/data/spooland/data/cacheowned by 65532 once (fly ssh console) if the server reports the spool directory not writable. Not verified on the platform. - Secrets:
fly secrets set XI_SIGNING_KEYS=… XI_SIGNING_SALTS=…. Private GHCR: deploy from an image Fly can pull (fly deploy --image, Fly’s registry).
flyctl config validate needs a Fly session and an app, so the files are checked against
SchemaStore’s fly.json instead. There is no maintained Terraform provider (the
fly-apps/fly provider is archived), so Fly has no Terraform module here.
Google Cloud Run
Section titled “Google Cloud Run”deploy/cloud-run/service.yaml (gcloud run services replace)
and service.video.yaml.
- How it scales: on requests in flight per instance (
containerConcurrency: 16, about 8 per vCPU) and CPU, betweenautoscaling.knative.dev/minScale(1;"0"scales to zero, with a cold start) andmaxScale(20). - CPU allocation:
run.googleapis.com/cpu-throttling: "false"keeps CPU allocated outside requests (instance-based billing), which background cache writes and stream prefetch need; the default (request-based) throttles it between requests.startup-cpu-boostand the second generation execution environment are on. - Probes: startup probe on
/readyz(1 s period, 30 tries), liveness on/healthz. - Shutdown: Cloud Run sends SIGTERM and kills 10 s later, so the files set
XI_SERVER_SHUTDOWN_DELAY=1sandXI_SERVER_SHUTDOWN_TIMEOUT=8s. - Registry: Cloud Run pulls from Artifact Registry (or Docker Hub): copy the GHCR image there or
use a remote repository;
PROJECTis a placeholder. - Secrets: Secret Manager through
secretKeyRef. - Metrics: the admin listener stays on loopback; scrape it with a sidecar container in the same service (Google’s Managed Service for Prometheus sidecar or an OpenTelemetry Collector), or rely on Cloud Run’s request metrics. Not included here.
- Video: Cloud Run has no local disk; the spool is an in-memory
emptyDirthat counts against the memory limit (8 GiB here,XI_VIDEO_TRANSFORM_SPOOL_MAX_BYTES=2GiB),timeoutSeconds: 300,containerConcurrency: 4, ingressinternal-and-cloud-load-balancingbehind a URL map that routes the video paths. Large-video workloads fit better on a platform with disks.
AWS ECS on Fargate
Section titled “AWS ECS on Fargate”deploy/aws-ecs/ has the AWS CLI inputs: task-definition.json
(aws ecs register-task-definition --cli-input-json file://…), service.json (create-service),
scalable-target.json (aws application-autoscaling register-scalable-target) and two policies
(put-scaling-policy). The Terraform, Pulumi, SST and
CDK programs build the same thing with the ALB.
- How it scales: Application Auto Scaling target tracking between
MinCapacity2 andMaxCapacity20, onECSServiceAverageCPUUtilization60 % and optionallyALBRequestCountPerTarget(requests per task per minute); scale-out cooldown 60 s, scale-in 300 s. With two policies the larger desired count wins and scale-in needs both to agree. No scale to zero on demand. - Task: 2 vCPU / 4 GiB on Graviton (ARM64),
readonlyRootFilesystem,user 65532,/tmpas a task volume (Fargate has no tmpfs; it is the task’s ephemeral storage), capabilities dropped, the image’s health check,stopTimeout: 40(Fargate allows up to 120). - Load balancer: target group health check
/readyz, deregistration delay 30 s, deployment circuit breaker with rollback, 100 % / 200 % deployment bounds. - Secrets: Secrets Manager through
secrets; a private GHCR needsrepositoryCredentials. - Metrics: loopback; add an ADOT or Prometheus sidecar container to scrape
127.0.0.1:9090. - Video: a second service with
XI_ROLE=video,ephemeralStorageup to 200 GiB for the spool and an ALB listener rule on the video paths.
AWS App Runner
Section titled “AWS App Runner”deploy/aws-app-runner/: autoscaling.json
(aws apprunner create-auto-scaling-configuration) and create-service.json. App Runner no longer
accepts new customers (since 2026-04-30; AWS points to ECS Express Mode); the files are for
existing accounts.
- How it scales: on concurrent requests per instance:
MaxConcurrency16,MinSize1 (cannot be 0; provisioned instances bill memory only),MaxSize10 (up to 25 by default). - 2 vCPU / 4 GB, HTTP health check on
/readyz, secrets from Secrets Manager. App Runner pulls only from ECR (copy the image), exposes one port, has 3 GB of ephemeral storage including the image and a 120 s request timeout: images only, no video fleet. The admin listener is not reachable.
Azure Container Apps
Section titled “Azure Container Apps”deploy/azure-container-apps/containerapp.yaml
(az containerapp create --yaml), and the Terraform module.
- How it scales: KEDA rules. An
httprule adds a replica perconcurrentRequests(8) in flight (measured over 15 s windows) and a customcpurule targets 65 %; the larger result wins.minReplicas1,maxReplicas20 (up to 1000). Scale-up is immediate, scale-down waits for a 300 s stabilization window.minReplicas: 0scales to zero with the http rule (the cpu rule does not wake a revision from zero). - 2 vCPU / 4 GiB on the Consumption profile, startup/readiness probes on
/readyz, liveness on/healthz,terminationGracePeriodSeconds: 40,/tmpon anEmptyDir(replica-local disk). - Secrets: app secrets (or Key Vault references) mapped with
secretRef; GHCR credentials as a registry secret. - Metrics: loopback by default; expose
:9090as an internal port (additionalPortMappings, internal ingress) for a collector in the same environment. - Video: a second app with
XI_ROLE=video; theEmptyDiris bounded by the replica’s ephemeral storage, so large spools need an Azure Files volume (not verified here).
Railway
Section titled “Railway”deploy/railway/railway.json (config as code): numReplicas: 2,
healthcheckPath: /readyz (used during deploys), drainingSeconds: 40 (SIGTERM to SIGKILL),
overlapSeconds, a restart policy and limitOverride for 2 vCPU / 4 GB. The image itself is set as
the service source in the dashboard or CLI (config as code has no image key); variables and secrets
in the service’s Variables.
- How it scales: Railway has no horizontal autoscaling: replicas are a fixed number
(
numReplicas, per region withmultiRegionConfig), and each replica scales vertically up to the plan’s limits. Traffic goes to the nearest region and randomly across its replicas.sleepApplicationstops an idle service (off here). - A service has at most one volume and volumes cannot be used with replicas: run a video service as
one replica with a volume, or keep the spool in memory.
:9090is reachable only on the private network (<service>.railway.internal) unless you expose it.
Render
Section titled “Render”deploy/render/render.yaml (a Blueprint) with an image web service
and a video web service.
- How it scales: autoscaling keeps average CPU (60 %) and memory (80 %) near their targets
between
minInstances(2; at least 1, no scale to zero) andmaxInstances(10). It needs a Professional workspace or higher;numInstancesis ignored whenscalingis set. plan: 2c-4g,healthCheckPath: /readyz,maxShutdownDelaySeconds: 40(video 150; maximum 300), secrets assync: false(asked for at Blueprint creation),PORT=8080. Private GHCR through a registry credential namedghcr.- A service with a persistent disk cannot scale, so the video service keeps its spool in the
container filesystem (
XI_VIDEO_TRANSFORM_SPOOL_MAX_BYTES=4GiB). The admin port is reachable on Render’s private network only.
DigitalOcean App Platform
Section titled “DigitalOcean App Platform”deploy/digitalocean/app.yaml (doctl apps create --spec): an
image service and a video service, and ingress.rules routing /video/, /q/video and
/_stream/ to the video one with preserve_path_prefix (App Platform strips the prefix otherwise).
- How it scales:
autoscaling.min_instance_count2 /max_instance_count10 on CPU 60 %. CPU autoscaling needs dedicated-CPU sizes (apps-d-*);requests_per_second.per_instanceandrequest_duration.p95_millisecondsmetrics are also accepted. No scale to zero. Do not setinstance_countwith autoscaling. - Readiness
health_checkandliveness_health_check,terminationwithdrain_seconds: 5andgrace_period_seconds: 40(video 150), secrets astype: SECRET, GHCR withregistry_credentials, the admin port as aninternal_portsentry. - App Platform has no persistent disk; the video spool lives in the container filesystem.
doctl apps spec validateneeds an API token (without one,--schema-onlyonly parses the YAML), so the spec is validated against DigitalOcean’s published OpenAPIapp_specschema instead.
deploy/modal/app.py, one Modal App with two classes, each its own web
endpoint and autoscaling pool: ImageFleet (-slim, XI_ROLE=image) and VideoFleet (-full,
XI_ROLE=video). Written for the modal client 1.6.2 (the 1.x API: min_containers,
scaledown_window, @modal.concurrent, not the older keep_warm, container_idle_timeout or
allow_concurrent_inputs).
pip install modal==1.6.2 && modal setupmodal secret create ghcr-pull REGISTRY_USERNAME=<github-user> REGISTRY_PASSWORD=<token-with-read:packages>modal secret create xtandard-image-signing XI_SIGNING_KEYS=<hex> XI_SIGNING_SALTS=<hex>modal secret create xtandard-image-stream XI_VIDEO_TRANSFORM_STREAM_KEYS=<hex>modal deploy deploy/modal/app.py- How the container runs: Modal is a Python platform. It builds its own image from ours
(
modal.Image.from_registry, which needspythonandpiponPATHoradd_python=; the image must have alinux/amd64variant, which ours has), runs its Python entrypoint through the image’sENTRYPOINT(which mustexecits arguments, and ours is the server binary), and always runs as root, ignoringUSER(existing images). So the file adds a standalone CPython (add_python="3.13"; the runtime is Ubuntu 24.04 with glibc, which it needs), clears the entrypoint (.entrypoint([])), and each container’s@modal.enterstartsxtandard-image servewithsubprocess.Popenas UID 65532 (the image’s user;MODAL_*variables are not passed on).@modal.web_server(8080, startup_timeout=…)proxies HTTP to it once the port accepts connections (web functions). There is no read-only root filesystem,--cap-dropor tmpfs to set. The server writes/tmp(world-writable in the image) and, on video, the spool and disk cache directories, whichstart_server()creates for UID 65532. - How it scales: every HTTP request is one Modal input. Without
@modal.concurrenta container takes one request at a time; with it, the autoscaler adds containers to keep abouttarget_inputsin flight per container and lets one burst tomax_inputs(input concurrency). Images: target 8, max 32 (just under 2 workers + a 32-place queue, so Modal queues or scales out before the server’s own 429). Video: 4 / 16. Betweenmin_containers(1 for images, 0 for video) andmax_containers(20 / 10), withbuffer_containers=1idle spare for images while traffic flows andscaledown_window=300(idle seconds before a container stops; 2 s to 20 min, default 60) (scaling, cold starts)./readyzis not used by Modal: it routes as soon as the port is open (XI_SERVER_READY_QUEUE_THRESHOLDhas no effect here), and the server’s 429 withRetry-Afterstays the backstop. - Cold start: Modal builds its image once, at
modal deploy; it says containers boot in about a second, and the server then starts before the port opens. Not measured on Modal.min_containers=1keeps one stills container warm. - Size:
cpu=(2.0, 2.0)andmemory=(4096, 4096)are (request, limit): a hard CPU limit and a memory limit that OOM-kills above it; without a limit a container may use more than its request (resources). Modal counts a physical core (2 vCPU) as 1.0. Modal does not document a cgroup quota ormemory.maxvisible inside the container, so the file setsGOMAXPROCS(= workers) andGOMEMLIMIT(30 % of the limit) explicitly instead of relying on the server’s cgroup detection. Video: 4 cores, 8 GiB,XI_VIDEO_TRANSFORM_WORKERS=4. - Request limits: an HTTP request to a web function may last 150 s
(timeouts; Modal then answers itself, with a 303
to a result URL on its Python web functions), request bodies up to 4 GiB, responses unlimited.
XI_VIDEO_TRANSFORM_TIMEOUTstays at 2 minutes and the functions’timeout=150. HLS/DASH playlists and segments under/_stream/are ordinary short requests. - Rate limit: new workspaces are limited to 200 web requests per second in total (429 above it, raised by Modal support) (web functions). Behind a CDN that counts only misses, but plan for it.
- Shutdown: when a container stops (scale-in, redeploy, preemption), Modal calls
@modal.exitwith a 30 s grace period (lifecycle, preemption); the file’s handler sends SIGTERM and waits. Modal does not probe/readyz, soXI_SERVER_SHUTDOWN_DELAY=0s, and the drain is 20 s for images and 25 s for video (not the 130 s of other video fleets: a transcode still running then is cut, and its client retries).nonpreemptible=Trueavoids preemption at 3 × the CPU and memory price. - Secrets: Modal Secrets become environment variables:
xtandard-image-signingwithXI_SIGNING_KEYS/XI_SIGNING_SALTS,xtandard-image-streamwithXI_VIDEO_TRANSFORM_STREAM_KEYS(the same for every video container), andghcr-pullwithREGISTRY_USERNAME/REGISTRY_PASSWORDfor the private GHCR package (required_keysmakesmodal deployfail early when one is missing). The names are placeholders. - Disk and cache: the container filesystem is ephemeral (
ephemeral_disk=in MiB raises it, up to 3 TiB, billed as memory at 1/20 of the size); the video spool and a per-container disk result cache live there and vanish with the container. A Modal Volume is not a shared cache: writes become visible to other containers only after a commit and a reload, the last commit wins, and v1 recommends at most 5 concurrent writers. Use the in-memory result cache, an S3-compatible bucket (XI_CACHE_RESULT_STORES=s3) for shared results, and a CDN in front. - Observability: only the web server port is exposed, so
/metricson the admin listener (loopback) cannot be scraped. Logs are the server’s JSON on stdout (modal app logs xtandard-image); for traces, putOTEL_EXPORTER_OTLP_ENDPOINT(and_HEADERS) in a Modal Secret and add it tosecrets=[…](OPERATIONS.md §6). Modal’s dashboard shows containers, inputs and CPU/memory per function. - Routing and domains:
modal deployprintshttps://<workspace>--xtandard-image.modal.runandhttps://<workspace>--xtandard-video.modal.run(thelabel=of eachweb_server); route the video paths to the second at the CDN.custom_domains=[…]on@modal.web_serverserves your own domain with a managed certificate on the Team and Enterprise plans (URLs); themodal.runURLs keep working. - Fit: Modal bills per second for the higher of request and use (2026-10 list price: $0.0000131 per core-second, $0.00000222 per GiB-second, so one always-warm 2-core / 4 GiB container is about $91 per 30 days; pricing) and scales to zero, which suits spiky or occasional traffic, a video fleet that is idle most of the day, or staging. It suits steady high-volume stills less: the 200 requests per second workspace limit, the 150 s request cap, a 30 s drain, no Prometheus scrape and no shared disk all work against it, and always-on containers cost more than a VM. GPUs bring nothing: libvips, FFmpeg and the ONNX detection and classification models run on the CPU here.
modal deploy needs an account, so task deploy:platforms:check loads the app with the pinned
client instead (any Modal deprecation warning fails it) and runs config validate on each fleet’s
environment; task deploy:platforms:run builds what from_registry(…, add_python=…).entrypoint([])
builds from $XI_IMAGE (root, a standalone CPython, the client) and runs the app’s own
start_server()/stop_server() for both fleets: /readyz 200, UID 65532, no MODAL_*
variables, the GOMEMLIMIT log line, a clean SIGTERM exit. Modal’s proxy, autoscaler and
signals are not exercised. Not verified on the platform.
Infrastructure as code
Section titled “Infrastructure as code”Each tool’s directory is a self-contained example, not a published module. Settings match the platform files above.
Terraform / OpenTofu
Section titled “Terraform / OpenTofu”deploy/terraform/, one root module per target, providers pinned to exact
versions (lock files are not committed: OpenTofu and Terraform use different registries):
| Module | What | Autoscaling |
|---|---|---|
cloud-run |
google_cloud_run_v2_service, Secret Manager secrets, a service account, public invoker |
scaling { min_instance_count, max_instance_count }, max_instance_request_concurrency, cpu_idle = false |
aws-ecs-fargate |
VPC inputs, ALB + target group (/readyz) + listener (HTTP, or HTTPS with a certificate), ECS cluster, task definition, service with circuit breaker, Secrets Manager, IAM, optional CloudFront in front (cloudfront.tf, cache key with Accept) |
aws_appautoscaling_target min/max, CPU target tracking and optional ALBRequestCountPerTarget, 60 s / 300 s cooldowns |
azure-container-apps |
resource group, Log Analytics, environment, azurerm_container_app |
min_replicas, max_replicas, http_scale_rule, custom_scale_rule (cpu) |
kubernetes-helm |
namespace, the Secret the chart loads through existingSecret (signing and stream keys), helm_release of deploy/helm/xtandard-image with the values of values.image.json or values.video.json (var.fleet): role, image.flavor (tag empty, so <appVersion>-<flavor>; a set image_tag must end in -<flavor>), config with the sources (var.sources replaces them), existingSecret, resources, video spool and disk cache for the video fleet |
autoscaling.enabled/type/minReplicas/maxReplicas (hpa by default; var.autoscaling_type = "keda") |
cd deploy/terraform/aws-ecs-fargatetofu init && tofu plan -var vpc_id=… -var 'public_subnet_ids=[…]' -var 'private_subnet_ids=[…]'Fly.io has no maintained Terraform provider; Cloudflare in front of any of these is a DNS record plus cache rules (OPERATIONS.md §4.2; CloudFront is in the ECS module, §4.3). Providers: hashicorp/google 8.8.1, hashicorp/aws 6.70.0, hashicorp/azurerm 5.11.0, hashicorp/helm 3.3.0, hashicorp/kubernetes 3.4.0.
Pulumi
Section titled “Pulumi”deploy/pulumi/, TypeScript with Bun (Pulumi.yaml sets
packagemanager: bun), one program and three targets: pulumi config set target aws|gcp|k8s.
aws(aws.ts):awsx.lb.ApplicationLoadBalancer(health check/readyz, deregistration 30 s) +awsx.ecs.FargateService(ARM64, read-only root,/tmpvolume, the image health check, circuit breaker) + an Application Auto Scaling target (min/max from config) with a CPU 60 % target-tracking policy.gcp(gcp.ts):gcp.cloudrunv2.Servicewith min/max instances, concurrency 16, CPU always allocated, Secret Manager secrets.k8s(k8s.ts):k8s.helm.v3.Releaseof../helm/xtandard-imagewith the values ofvalues.image.jsonorvalues.video.json(configfleet), read bychart-values.tswith the overridesimageFlavor,imageTag,sources(an object withXI_SOURCESand its keys),autoscalingType,minReplicas,maxReplicas; it creates the Secret named byexistingSecret(signing keys,streamKeys).
Both Kubernetes programs keep their chart values in those JSON files only. deploy/check/helm.sh
checks that each program’s values equal its file when nothing is overridden and renders every file
through helm lint --strict and helm template | kubeconform -strict, with WP-148’s pinned helm
and kubeconform images.
cd deploy/pulumi && bun installpulumi config set target aws && pulumi config set --secret signingKeys <hex> && pulumi config set --secret signingSalts <hex>pulumi previewdeploy/sst/sst.config.ts: sst.aws.Vpc + sst.aws.Cluster +
sst.aws.Service (SST 4.17; the Service and Cluster components are the v3 API, v4 moved the
underlying Pulumi AWS provider to v7). scaling: { min: 2, max: 20, cpuUtilization: 60, memoryUtilization: 80, requestCount: 1200, scaleOutCooldown: "60 seconds", scaleInCooldown: "300 seconds" }, cpu: "2 vCPU", memory: "4 GB", ARM64, ALB rule 80/http → 8080/http with the
/readyz health check, the container health check, secrets through ssm (Secrets Manager ARNs),
and a transform that makes the root filesystem read-only with a /tmp volume, UID 65532 and a
40 s stop timeout.
cd deploy/sst && bun install && bunx sst install # generates .sst/platform (types)XI_SIGNING_KEYS_ARN=… XI_SIGNING_SALTS_ARN=… bunx sst deploy --stage productionAWS CDK
Section titled “AWS CDK”deploy/cdk/: ApplicationLoadBalancedFargateService with an explicit
FargateTaskDefinition (ARM64, 2 vCPU / 4 GiB, read-only root with a /tmp volume, UID 65532,
40 s stop timeout, the image health check, Secrets Manager secrets), target group health check
/readyz and 30 s deregistration, circuit breaker with rollback, and
autoScaleTaskCount({ minCapacity: 2, maxCapacity: 20 }) with scaleOnCpuUtilization (60 %) and
scaleOnRequestCount (1200 per target), 60 s / 300 s cooldowns. The stack is environment-agnostic,
so cdk synth works without credentials.
cd deploy/cdk && bun install && bunx cdk synth && bunx cdk deployVerification
Section titled “Verification”Two tasks, both runnable without a cloud account (Docker and Bun on the host; every other tool runs from a pinned container or a pinned package):
task deploy:platforms:check # everything below except the local runtask deploy:platforms:run # compose single + split + Swarm + Modal-like container against $XI_IMAGE (default xtandard-image:main-gpl)| Check | Tool | Files |
|---|---|---|
| Compose and stack files render | docker compose config, docker stack config |
compose, split, Swarm, Dokploy, Coolify, Openship |
| nginx router | nginx -t (nginx 1.29) |
deploy/compose/nginx.conf |
| JSON Schemas | check-jsonschema 0.38.2 |
fly.toml (SchemaStore fly.json), railway.json (railway.schema.json), render.yaml (render.yaml.json) |
| OpenAPI schemas, unknown keys rejected | deploy/check/validate.py |
DigitalOcean app spec (app_spec of DigitalOcean’s public OpenAPI), Container Apps YAML (ContainerApp of the Azure REST spec 2026-01-01) |
| AWS inputs | botocore 1.43 service models | ECS task definition, service, scalable target and policies; App Runner service and autoscaling |
| Cloud Run YAML | kubeconform 0.7 with the Knative Serving v1 schema (Cloud Run annotations are not checked) | deploy/cloud-run/*.yaml |
| Nomad | nomad job validate (Nomad 2.0.7, no agent) |
deploy/nomad |
| Modal app | deploy/check/validate.py modal: the app loads with modal 1.6.2 (no credentials; deprecation warnings are errors), one web endpoint per env dict |
deploy/modal/app.py |
| The server accepts each environment | xtandard-image config validate in $XI_IMAGE (unknown XI_* variables, bad values, roles) |
every container of the compose, Swarm, Dokploy, Coolify, Fly, Cloud Run, Container Apps, Render, DigitalOcean, ECS and App Runner files, and both Modal fleets |
| Terraform | OpenTofu 1.13.2 fmt -check, init -backend=false, validate, tflint 0.65 (aws, google, azurerm rulesets); also passes with XI_TF_IMAGE=hashicorp/terraform:1.17.0 |
deploy/terraform/* |
| TypeScript | bun install --frozen-lockfile, tsc --noEmit, sst install, cdk synth without credentials |
deploy/pulumi, deploy/sst, deploy/cdk |
| Helm values of the Kubernetes programs | tofu console on local.values and chartValues() compared with the JSON files; helm lint --strict, helm template piped to kubeconform -strict (WP-148’s pins, read from deploy/scripts/check.sh) |
deploy/terraform/kubernetes-helm/values.*.json, deploy/pulumi/values.*.json |
| Node licences | bun deploy/check/licenses.ts |
the three Node projects |
| Scripts | shellcheck 0.11 | deploy/check/*.sh, deploy/docker/run.sh |
task deploy:platforms:run (deploy/check/compose-run.sh, host
port 18480 by default) brings up compose.yml with the fixtures as a local source, then
compose.split.yml with two image replicas, one video replica and nginx, and checks /healthz,
/readyz, a signed image (200), an unsigned one (403), that 9090 is not published, that /video/…
(a signed WebM transcode, 200), /q/video and /_stream/… reach the video fleet and images the
image fleet, and that each fleet refuses the other’s requests directly. Then, only if the host is
not already a swarm node, it creates a single-node swarm, deploys stack.yml, checks 2/2 replicas
and the update/rollback settings, removes the stack and leaves the swarm. Everything it creates is
removed (down -v). Last, deploy/check/modal-run.sh runs the
Modal app’s server lifecycle in a Modal-like container (Modal) and removes that image.
No GitHub workflow runs these yet (Actions minutes are exhausted); the tasks are the entry point.
Licences
Section titled “Licences”The Node tooling in deploy/pulumi, deploy/sst and deploy/cdk is development-only: it is run to
describe or deploy infrastructure and never ships in an image or a client. Each directory has its own
package.json and bun.lock outside the root Bun workspace. deploy/check/licenses.ts applies the
ADR 0006 allowlist (MIT, BSD, Apache-2.0, ISC) and additionally accepts BlueOak-1.0.0, CC0-1.0 and
Python-2.0 (permissive; isaacs’ glob/minimatch/tar family, SPDX data, argparse), with two named
exceptions: spdx-exceptions (CC-BY-3.0 data) and @logdna/tail-file (MIT licence file, no SPDX
field). Pulumi SDKs, SST and the AWS CDK are Apache-2.0.
Terraform providers, OpenTofu, Terraform, tflint, Nomad, kubeconform and check-jsonschema are
external tools that are only run (ADR 0006): the HashiCorp providers are MPL-2.0, OpenTofu is
MPL-2.0, Terraform and Nomad are BUSL-1.1, tflint is MPL-2.0 (its rulesets MPL-2.0), kubeconform
Apache-2.0, check-jsonschema Apache-2.0. The modal client (Apache-2.0) and the standalone CPython
(PSF-2.0) are only installed by the checks and by your own modal deploy; nothing of them ships in
the published images.