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. Queue workers, pollers and scheduled tasks are counted healthy by ECS the moment they reach
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) almost no trace is left.
The schema-version boot gate
Declaremigrations: once on a database. Every service whose connections: include that database then gets two things at synthesis: the expected schema version injected as environment variables, and a synthetic gate container that verifies the live database before any application container starts.
Declare the migrations directory
dir, not path. tool: "prisma" uses the built-in ^\d{14}_/ directory-name resolver. For Drizzle, Flyway, Rails or any other tool, supply your own resolver:
migrations: block of their own, with dir and an optional versionResolver. It carries no tool field, because the default resolver picks the lexicographically latest *.sql file under dir.
Injected environment variables
The materialised fjall-schema-gate container
By default Fjall prepends a synthetic container named fjall-schema-gate to every eligible service, and gives every other container in the task a dependsOn: [{ container: "fjall-schema-gate", condition: "SUCCESS" }] edge. The gate connects to the live database as a read-only identity, compares the applied schema against the expected version, and exits.
A non-zero exit stops the task before it reaches RUNNING. That counts toward the deployment circuit breaker, so a schema refusal rolls the deployment back rather than crash-looping invisibly. Enforcement does not depend on your application implementing anything.
In init-container migration mode the ordering is migrate, then gate, then app. The migrate container never waits on the gate, because it runs against the pre-migration schema by definition.
Gate exit codes
When targets disagree,
configError wins first, then refused, then denied, then busy, then connectionError.
Reading the exit code during a deploy
The gate’s exit hint rides the ECS tail’s stopped-task progress line, not a standalone callout:[ecs] prefix appears under --non-interactive. In an interactive run the same line renders in the ECS operation box, truncated to the box width, and lands in full in the deploy log file. The final error block repeats the hint only when the ECS completion is the classifier’s source. A captured CloudFormation failure takes precedence and produces a cfn.* record with no gate hint, so read the ECS tail rather than waiting for the summary.
When the gate container does not materialise
Materialisation turns itself off, with a synth warning, in these cases. Environment injection stays on, so your own boot check still works.
The two
relational half rows are not all-or-nothing. The relational and ClickHouse halves are resolved independently, so a service whose ClickHouse half is eligible still materialises a gate container even when the relational half is skipped for a non-Prisma tool or a non-postgres engine. The other three rows disable materialisation outright.
Scheduled tasks never materialise a gate container. They receive the environment variables, and their own entrypoint decides whether to act on them.
Per-service control
An author-set
EXPECTED_SCHEMA_VERSION in a service’s environment: block wins inside that container, with a synth warning. The materialised gate container still enforces the resolved value. Use schemaGate: { materialise: false } when the author value must be authoritative.
A service connected to two migrated databases of the same kind is rejected at synth, because the gate cannot inject one expectation unambiguously. Split the service or set schemaGate: false.
Adding an application-side check
An application-side gate is optional now, and still useful for local runs, integration tests and CLI checks.verifyExpectedSchemaVersion from @fjall/util/migration queries the live database and returns the comparison. It never exits the process, so your boot code owns the exit code and the log shape.
- Rollbacks pass. An older image booting against a database that has already applied a newer expand-only migration starts cleanly (
actual >= expected). Expand and contract discipline guarantees the old code’s columns still exist. - Forward skew refuses. New code on an older schema (
actual < expected) fails, because the code may read columns the database lacks. - A never-migrated database refuses.
actualisnullwhen no migration has been applied, and that never matches.
.sql filename, whose leading numeric prefixes are the same width. Prisma’s fixed 14-digit timestamps always qualify.
Anything else falls back to strict identity, which refuses the rollback: a tool: "custom" version that is a content hash rather than a name, and ClickHouse filenames whose NNN- prefixes differ in width or are absent. Zero-pad ClickHouse migration filenames to a fixed width so rollbacks stay tolerant there too.
The deployment circuit breaker
Every Fjall ECS service ships with the deployment circuit breaker enabled and set to roll back:Options
circuitBreaker: false disables the breaker entirely. Only do this if you run your own deployment verification.
The task-stop watchdog
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. It watches task-state-change events withlastStatus: 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 are deliberately excluded so the signal stays clean: scale-in and deploy draining (ServiceSchedulerInitiated), operator StopTask (UserInitiated), SpotInterruption and TerminationNotice.
Without an
alertsTopic on the cluster, the forensic capture and the metric still run and only the alarms are skipped. A service carrying alarms: false is excluded from the churn alarms too, while its stops still land in the log and the metric. Standalone and scheduled tasks (family:* groups) are logged for forensics but raise no alarm, because they have no service to dimension on.
When the alarm fires, the log group answers why:
fjall target list to see the target names available to you. They carry a region suffix, such as production-euw1 or production-use2.
Each entry is the raw ECS event. Read detail.stoppedReason, detail.stopCode and detail.containers[].exitCode. An exit code from the gate taxonomy above on the fjall-schema-gate container tells you the deployment was stopped on purpose.
Opt out per cluster with taskStopWatchdog: false on EcsCluster or the ECS compute pattern. Cost is modest, roughly 0.70 per service per month in CloudWatch alarm and log charges.
The watchdog grants EventBridge write access to its log group with a native
AWS::Logs::ResourcePolicy, one policy per log group. AWS caps account-level
CloudWatch Logs resource policies at 10 per region. If you run many clusters
in one 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 used to hide. Three practices close the gap.- Leave the gate materialised. With the default gate container, a worker that would boot against the wrong schema is stopped pre-
RUNNING, which the circuit breaker counts and rolls back. If you setschemaGate: { materialise: false }orschemaGate: false, you give that rollback up and your own boot check becomes the only enforcement. - 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 for post-boot crashes. The gate covers schema refusals. A container
healthCheck, even a trivial process liveness probe, moves later crashes from an invisible post-RUNNINGexit into territory the circuit breaker can act on.
Next Steps
Destructive Changes
Consent gates for replacements, deletions and remediation verbs.
Circuit Breaker Errors
Diagnose a deployment that ECS rolled back.
ECS Cluster
Service, container, scaling and health-check options in full.
Engine Compatibility
Keep the CLI and your infrastructure code on compatible versions.