Readiness gate
A new container takes traffic only when all of these hold:- its readiness probe passed
APP_DEPLOY_SUCCESS_THRESHOLDtimes in a row (default2; a failed probe resets the streak), - it has been running for
APP_DEPLOY_STABLE_WINDOW(default10s), - it did not exit, restart or get OOM-killed inside that window. The
controller compares Docker’s
RestartCount,StartedAt,OOMKilledand exit code against what it saw when the window began.
OOMKilledDuringStableWindow,
ExitedDuringStableWindow, RestartedDuringStableWindow,
StabilityGateTimeout, or the existing ReadinessFailed,
OOMKilledDuringReadiness, ExitedDuringReadiness). The previous release
never stops serving: ingress keeps routing to it because the new release
never reports ready. The next reconcile retries the new release under the
crash restart backoff.
Multi-replica releases are gated replica by replica. With blue-green,
nothing from the old release is retired until every new replica passed its
own gate, so a release where replica 0 passes and replica 1 is OOM-killed
leaves the old release fully in place.
Drain
After cutover the previous release keeps running for at leastAPP_DEPLOY_DRAIN_TIMEOUT (default 15s), measured from the cutover
itself, so requests already in flight on it finish before it is stopped.
Ingress moves new requests to the new release in the same reconcile pass.
With a drain configured, rolling deploys also stop retiring old replicas
mid-pass: they all drain together after the pass, because ingress keeps
balancing over them until then. The drain timeout is a floor on the hold
described in Holding the previous release.
Rollback pins
Every release that takes traffic is pinned: its image gets an extra local tag (<short-name>-rollback/<app>:<deploy-id>) and a row in the store. A
tagged image is never dangling, so image cleanup can never remove a
rollback target, even after a rebuild moved the original tag to new
content. The newest APP_DEPLOY_ROLLBACK_PINS (default 3) per app are
kept; older pins are untagged without force, so Docker still refuses to
remove an image a container uses.
Rolling back to a deploy (dashboard Roll back to this,
apps deploys rollback-to, POST .../deploys/{id}/rollback) deploys its
registry digest when it has one, otherwise its pin. It restores that
deploy’s config too: literal env values, port, health checks, replicas,
strategy, resources and labels. Secret, database and vault references keep
their current values, and domains, volumes and host ports stay as they are
now. ?config=false keeps the current config. The rollback is an ordinary
deploy, so it passes the same readiness gate.
Post-deploy watch
ForAPP_DEPLOY_WATCH_WINDOW (default 5m) after cutover the release is
watched every APP_DEPLOY_WATCH_INTERVAL (default 10s). The watch trips
when:
The 5xx share comes from the ingress request metrics the app already has.
The first metrics sample after cutover is left out, since it can still hold
answers from the previous release.
When the watch trips, the deploy attempt is marked failed with the reason,
an alert goes out through the app’s deploy notification channels, and with
APP_DEPLOY_AUTO_ROLLBACK=true (the default) the app is rolled back to the
newest pinned release that is still marked succeeded, with that release’s
config. A release the watch already tripped on is failed, so rollbacks never
ping-pong, and a release that is itself an automatic rollback is never
rolled back again: that points at something outside the app, so it is left
for an operator. With auto-rollback on, the previous release is held for the
whole watch window, so the rollback target is still running if the watch
trips.
Gate timeline
Each deploy records its gate as a timeline: readiness, stable window, cutover, drain, rollback pin, watch and rollback steps with a status, a reason and a message. The deploy detail page shows it under Roll out, refreshing until the release clears its watch, fails or is replaced.deploy status exits 7 when the release failed its gate, tripped its
watch or was rolled back. The API is
GET /api/v1/apps/{name}/deploys/{deployId}/gate (latest works as the
id).
Per-app settings
EveryAPP_DEPLOY_* value above is the platform default. An app can
override the gate and watch thresholds in Deploy settings → Deploy
safety, with the CLI, or with PUT /api/v1/apps/{name}/deploy-safety
(deploy permission). A null field, an empty dashboard field or the CLI
value default inherits the platform default. Windows are capped at 30
minutes so one app cannot stall the reconcile loop.
How this is tested
test/e2e/reconcile/deploy_safety_live_test.go runs the real application
and ingress controllers on a real reconcile engine against Docker, with the
request metrics pipeline and the watch, while four clients hammer the
ingress. It deploys a good release, a release that exits inside its stable
window, and a release that answers 5xx until the watch rolls it back. The
fixture app drops in-flight requests on SIGTERM, so only a real drain
keeps them alive. A run on Docker Desktop: 2617 requests, 0 failed
(transport errors or proxy 5xx), the bad release’s own 500s excluded. With
the drain turned off the same test loses requests at cutover.
Digest-truthful deploys
A floating tag such asnginx:latest, main or 1.27 names different
content over time. If a platform compares tags or container names, a
redeploy of the same tag can report success while the old image keeps
running.
Every image deploy (dashboard, apps deploy, API, pipeline deploy step,
build.type: image in app.yaml) resolves the tag to its content
digest at deploy time and pins the desired image to it, for example
nginx:latest@sha256:3f1c.... For a multi-arch image the digest is the
manifest list, so each node still pulls its own platform. Because the
pinned reference is part of the container identity, new content always
gets a new container and goes through the normal blue-green, rolling or
recreate cutover.
Builds are identified by the local image ID the build produced. Rebuilding
the same commit with different content (a newer base image, different
build args) therefore replaces the container instead of silently keeping
the old one.
The application controller then checks that the running container’s image
ID matches what the pinned reference resolves to on its node. If it does
not, the app is reported Ready: False with reason ImageDigestMismatch
and the previous release keeps serving. It never reports Deployed while
the running content differs.
How the digest was obtained
Each deploy in the history carries a digest and a reason:
To refuse a cached fallback, deploy with pull:
--pull (API: "pull": true) a deploy fails with 502 when the
registry is unreachable instead of deploying the cached image.
apps deploys list shows the digest, the rollout state the controller
last observed (serving or mismatch) and any reason.
Rollback
History rows store the pinned reference, so rolling back to a row deploys exactly the content that row ran, even for a floating tag that has since moved. Older rows that hold only a tag are re-resolved when you roll back to them.Stale-deploy guard
Webhooks can arrive out of order, and a slow build can finish after a faster, newer one. Every automatic deploy (git push, release event, pipeline deploy step, a released freeze hold) gets a per-app sequence number when it is triggered, plus the commit ordering the git provider sent (before and after SHAs and the head commit timestamp). When it is
about to write desired state it is compared, in the same transaction, with
the last deploy that actually applied:
- a deploy triggered earlier than one already applied is
superseded; - a push whose commit is the parent of the deployed commit, or whose
commit is older by timestamp, is
supersededwith astale commitreason; - redelivery of the commit that is already deployed is allowed.
Freeze windows
A freeze window starts at every match of a 5-field cron expression, evaluated in a timezone (default UTC), and lasts a duration. Windows can be set per app and globally; both apply. While a window is active:- git push and release webhooks, and pipeline deploy steps, are held:
recorded as
Held (frozen)in the deploy history and replayed automatically once the window ends. If several deploys are held for one app, only the newest is replayed; the others are marked superseded. - manual deploys from the dashboard, CLI or API return 423 unless they carry an override with a reason. The reason is recorded on the deploy.
PUT /api/v1/settings/deploy-freeze
(root only). The MCP server exposes get_deploy_freeze read-only.
Held deploys are checked every APP_DEPLOY_HELD_RELEASE_INTERVAL
(default 30s).
Holding the previous release
After a successful blue-green or rolling cutover the most recent previous release’s running containers are kept forAPP_DEPLOY_PREVIOUS_RELEASE_HOLD
(default 3m, 0 disables). The window is measured from the newest
release’s first container, so scaling up does not extend it. Only one
previous release is ever held: as soon as a newer previous release exists,
older running releases are removed, so deploying five times in a minute
leaves the current release plus one previous one, not five containers.
During the window a rollback to the held release is instant (no pull, no
cold start), and routes that still point at it keep working. Once the window
passes, the next reconcile removes it. Rolling deploys retire old replicas one at a time but keep the old release’s first replica, the one ingress still dials, until the pass ends, so routing never points at a removed container. The recreate strategy never holds
anything, since it stops the old release before starting the new one.
GET /api/v1/apps/{name} reports previous_release_held_until (RFC3339)
while a release is held, and levelrail-cli apps status <name> prints it.
What visitors see during a deploy
The ingress never serves a bare502 or a TLS error for a domain that was recently routed. When a deploy leaves a domain without a ready container (a recreate deploy, a crash, a restart), the domain keeps its route and its certificate for APP_INGRESS_HOLD_WINDOW (default 10m) and answers a styled 503 with Retry-After and a page that reloads itself. A request whose container refuses the connection is retried for APP_INGRESS_RETRY_WINDOW (default 2s) first, and with two or more replicas it moves to another replica immediately. The windows that remain, with measured numbers, are listed in resilience, and every knob is in Edge limits, client IPs and failover.
Pinned host ports
An app with a pinned host port (--host-port) cannot run two releases at once, because the port can only be bound once. For these apps blue-green and rolling deploys stop the serving release, start the new one, and wait for readiness. This is a short outage (the new container’s start plus its readiness time), not a zero-downtime cutover. If the new release fails to start or never becomes ready, the new container is removed and the previous release is started again on the same port, and the next attempt is delayed by APP_DEPLOY_PINNED_PORT_RETRY (default 5m) so a broken image does not repeat the outage every reconcile pass. The condition reason is PinnedPortHandoffBackoff while waiting. Apps without a pinned port keep the full overlap and are unaffected. A container that Docker starts without publishing the pinned port is removed and recreated (PinnedPortNotPublished) instead of being reported as healthy.
Required secrets
A secret declaredrequired: true that has no value fails the deploy with reason RequiredSecretMissing and leaves the serving release untouched, whichever way the deploy was triggered.
Queue, cancel and rollback to a release
Deploys of one app run one at a time. A build or webhook deploy requested while another deploy of the same app is running (or already queued) is recorded asqueued instead of failing or racing, and starts by itself
when the deploy ahead of it finishes or is canceled. Queued deploys survive a
control plane restart. deploy-attempts rows carry queued_at,
queue_position, wait_reason (for example waiting for #dep_x,
freeze window until <time>, waiting for build capacity) and blocked_by.
Held (frozen) rows report the same wait_reason. GET /api/v1/deployments
and its stream carry the same queued_at, queue_position, wait_reason,
blocked_by, superseded_by and canceled_by fields. Freeze windows and
protected-environment approvals behave exactly as before; a deploy pending
approval is still an approval, not a queue row.
APP_DEPLOY_MAX_CONCURRENT caps how many deploys run at once across all apps
(default unlimited); deploys over the cap wait as queued.
POST /api/v1/apps/{name}/deploys/{deployId}/cancel cancels a queued or
running deploy (same permission as triggering one) and records canceled with
canceled_by. A running build is stopped before it writes desired state, so
the serving release is never touched. Once a deploy has written desired state
it can no longer be canceled and the call answers 409; roll back instead.
PUT /api/v1/apps/{name}/cancel-superseded ({"enabled": true}, default off)
lets a newer queued deploy replace older queued deploys of the same branch.
They become superseded with superseded_by pointing at the newer one. A
deploy that already started building is never canceled automatically.
POST /api/v1/apps/{name}/deploys/{deployId}/rollback redeploys the exact
content a past succeeded deploy recorded, pinned by digest or by its
rollback pin, with its config, through the same freeze,
approval and readiness gates as a plain deploy. It answers 410 when the local
image was garbage collected, 409 when its tag now points at other content or
the row recorded no digest.
cancel_deploy.
Cleanup never touches a rollback target
Pruning removes only dangling (untagged) images, and Docker itself refuses to remove an image a container still uses, even when its tag has since moved to newer content. A pinned rollback tag is never dangling, so it is never a candidate. Both properties are checked against a real daemon ininternal/docker/prune_images_live_test.go: a pinned tag, an untagged image still backing a running container, and a truly orphaned image go in, and only the last one comes out. A rollback whose image is gone anyway answers 410 with an instruction to redeploy from source instead of a half-working deploy. See Delete and clean up for the container, volume and certificate reaper.
Limits
- On remote nodes reached over the agent transport the running image ID is not yet reported, so the controller cannot detect a mismatch there; the digest pinning itself still applies.
- Rollback pins are only made for apps on the control plane’s own node; a remote node’s images rely on the registry digest. Restart counts and start times are not carried over the agent transport yet, so on a remote node the stable window detects exits and OOM kills but not a restart the container survived.
- The drain and the cutover time used by the watch live in memory; after a control plane restart the hold falls back to container creation times.
- Digest resolution runs on the control plane’s Docker daemon. A private registry needs a registry credential on the app.
- Apps created or edited through the plain app create/update and compose paths keep the image reference exactly as given until their next deploy.