What can go wrong, and what catches it
A deploy that ships a task which cannot boot — wrong schema, missing secret, broken image — fails in one of two very different ways:- Loudly, when the service sits behind a load balancer: the new task fails its health checks, the deployment circuit breaker trips, and ECS rolls back to the previous revision automatically.
- Silently, when the service has no health check — a queue worker, a poller, a scheduled task. ECS counts a worker task healthy the moment it reaches
RUNNING; a container that starts and then exits is invisible to the circuit breaker’s failure arithmetic. The deployment can report successful while the service crash-loops, and once ECS expunges the stopped tasks (about an hour) there is almost no trace left.
The schema-version boot gate
Declaremigrations: once on a database and every service whose connections: include that database receives an EXPECTED_SCHEMA_VERSION environment variable at synthesis — the name of the latest migration the deployed code was built against. (EXPECTED_CH_SCHEMA_VERSION is the ClickHouse counterpart.)
verifyExpectedSchemaVersion helper from @fjall/util/migration queries the live database and compares — and exits before serving traffic if the schema is behind the code. That closes the “new code, old schema” window: the failure is one structured log line at boot, not scattered runtime errors on unlucky requests.
The comparison is deliberately tolerant in the safe direction:
- Rollbacks pass. An older image booting against a database that has already applied a newer expand-only migration starts cleanly (
actual >= expectedfor orderable migration names). Expand/contract discipline guarantees the old code’s columns still exist, so a rollback must never be refused at boot. - Forward skew refuses. New code on an older schema (
actual < expected) exits — the code may read columns the database lacks.
schemaGate: falseon a service opts it out of auto-injection entirely; inject the variable yourself if you still want the gate.- An author-set
EXPECTED_SCHEMA_VERSIONin a service’senvironment:block wins over the injected value (with a synth-time warning). - A service connected to two migrated databases of the same kind is rejected at synth — the gate cannot inject one expectation unambiguously. Split the service or set
schemaGate: false.
Web services get deployment-level enforcement for free: a boot-refusing task
fails its health checks and the circuit breaker rolls the deployment back.
Workers get the correctness half (no wrong-schema processing) but not the
rollback half — see the worker guidance below.
The deployment circuit breaker
Every Fjall ECS service ships with the deployment circuit breaker enabled and set to roll back:Hardening knobs
Two properties tighten the breaker beyond AWS defaults (available from@fjall/components-infrastructure 9.1):
circuitBreaker: false disables the breaker entirely — only do this if you run your own deployment verification.
The task-stop watchdog
From@fjall/components-infrastructure 9.1, every ECS cluster gets a task-stop watchdog by default: a per-cluster EventBridge rule that captures abnormal task stops into a durable log, derives a per-service metric from them, and alarms on churn.
What it watches: task-state-change events with lastStatus: STOPPED and a stop code of EssentialContainerExited (any crash after start, including crash loops) or TaskFailedToStart (image pull, secret resolution, dependency-condition failures). Routine stops — scale-in, deploy draining, operator StopTask, Spot interruption — are deliberately excluded so the signal stays clean.
What it creates:
If the cluster has no
alertsTopic, the forensic capture and metric still run; only the alarms are skipped. A service opted out of alarms with alarms: false is likewise excluded from the churn alarms — its stops still land in the forensic log and the metric.
When the alarm fires, the log group answers why:
detail.stoppedReason, detail.stopCode, and detail.containers[].exitCode.
Opt out per cluster with taskStopWatchdog: false (on EcsCluster or the ECS compute pattern). Cost is modest — roughly $0.50–0.70 per service per month in CloudWatch alarm and log charges.
The watchdog grants EventBridge write access to its log group via a native
CloudWatch Logs resource policy. AWS caps these at 10 per account per
region, and each cluster’s watchdog uses one. If you run many clusters in a
single account and region and approach the quota, opt out of the watchdog on
the clusters you care least about — or ask AWS Support to raise the limit.
Deploy-time visibility
Duringfjall deploy, the engine tails ECS service events for the services in the stack while CloudFormation converges. Task stops, failed placements, and circuit-breaker rollbacks appear in the deploy output as they happen, rather than the deploy reporting only CloudFormation’s final verdict. A deployment that CloudFormation calls successful but whose service is churning tasks is surfaced in the same terminal that ran the deploy.
Guidance for worker services
Services without a load balancer are where deployment failures hide. Three practices close the gap:- Keep the boot gate on. The auto-injected
EXPECTED_SCHEMA_VERSIONgate means a mis-ordered deploy refuses at boot with one structured log line instead of corrupting work mid-message. Wire the check into your worker’s entrypoint withverifyExpectedSchemaVersionfrom@fjall/util/migration— the same helper and tolerance semantics web services use. - Let the watchdog alarm reach a human. Set
alertsTopicon the cluster. A crash-looping worker then pages within minutes, with a 30-day forensic record of every stop, instead of failing silently until queue-depth symptoms surface. - Give the breaker something to count where you can. A container
healthCheck(even a trivial process liveness probe) moves failures from “invisible post-RUNNINGexit” towards territory the circuit breaker can act on, restoring automatic rollback for boot-time failures.