Migrating to atc from Mender or balena
Who this is for: you run an existing OTA/fleet-update system — Mender or balenaCloud — and you want to move your update pipeline onto atc. This guide maps each platform’s concepts onto atc’s, walks the device and CI migration step by step, and is explicit about what atc does not replace today so you can plan for it.
Read onboarding.md first if you have never pushed an update
with atc — this guide assumes you’ve seen that flow once. Nothing here requires
this repository; you work with the released atc binary.
The atc model, mapped to what you already know
atcis a controller CLI on your machine (or CI runner). It delivers artifacts to a device over SSH — there is no always-on agent connection to a cloud backend, and no server between you and the device.atcdis the on-device daemon. It owns a content-addressed store at/var/atcand applies updates atomically. It does not poll anything; it acts when the controller contacts it.- An artifact is either a bundle (a directory tree — your app, config,
systemd units, scripts) or an OCI image (a container image). You declare
them in an
atc.tomlmanifest. - Every push is versioned and atomic: bytes are staged first, and the
active version flips only when you say so (
--switch). Rollback is a flip back to a version already on the device — nothing is rebuilt or re-downloaded. - Delta transfer is built in and automatic. The daemon announces what content it already has; the controller sends binary diffs (rsync-style or bidiff, chosen per blob) against it. There is no delta artifact to generate in CI, no read-only-rootfs requirement, and no commercial gate.
- Lifecycle hooks (scripts shipped inside your artifact) run at defined states — download, install, commit, rollback, failure — replacing Mender state scripts and the “what happens on update” half of balena’s supervisor.
What atc does not replace (plan for these up front)
Both Mender and balena bundle concerns that atc leaves to you. Be honest with yourself about which of these you actually use:
| Concern | Mender / balena answer | With atc |
|---|---|---|
| Host OS / full rootfs updates | Mender A/B partitions; balena HUP | Not today. atc delivers application-level artifacts, and no atc command pushes an OS. Keep your existing OS provisioning (your Yocto/Debian pipeline, or a stock distro image); atc runs on top of it. This is the one row that is moving, so read Where the OS tier stands before you plan around it. |
| Device connectivity | balena Cloudlink VPN; Mender websocket add-ons | SSH reachability is your job. Devices on LANs or behind NAT typically need an overlay network (WireGuard, Tailscale, …). If you can ssh user@device, atc works. |
| Remote terminal / port forward / public URLs | Add-ons / built-in | Plain SSH (and atc shell for a hook-identity shell on bootstrapped devices). No HTTP ingress to devices. |
| Cloud-held device variables | balena fleet/device variables; Mender Configure | Config ships as content: put env files or config trees in a bundle and version them with everything else. No out-of-band variable store to drift. |
| Hosted dashboard / device inventory | Web UIs | CLI: atc ota status, atc ota ls, --json for machines. |
| Autonomous update agent | Devices poll and self-update | Push model. Nothing changes on a device unless you push to it. Fleet rollout is a loop over your device list in CI — which also means phased rollout, canaries, and maintenance windows are ordinary CI logic, not platform features. |
If your product depends hard on one of the left-column items (e.g. you ship full-OS images to devices you can never re-provision), migrate the application layer to atc and keep your OS-update mechanism for the rare base-image change. The two coexist fine.
Where the OS tier stands
The first row of that table used to be a settled design position. atc ran above the OS and meant to stay there. That position is going away, slowly, so here is exactly what exists.
A device boots two root filesystem slots. It spends one trial boot on the new
slot. When nothing confirms that boot, the bootloader puts the old slot back,
with no help from userspace. One qemu guest ran that sequence on 2026-09-17,
from a Yocto image with a GRUB slot layout. The layer is a laboratory result
and not part of a release, and no hardware target has the layout. No atc
command drives any of it, and there is no date.
Plan your migration on the table above. Nothing else in this guide depends on that work landing.
Device requirements
You need SSH access (key auth), passwordless sudo for the one-time bootstrap,
and systemd as PID 1 (or the
no-systemd recipe).
atcd is a static musl binary for x86_64 and aarch64 Linux.
If your devices don’t run systemd as PID 1 (containers, minimal images),
expect a real operational difference from Mender’s systemd-integrated setup.
atc bootstrap cannot stage a unit to restart atcd for you, and the
no-systemd recipe’s runtime dir lives on tmpfs, so it does not survive a
restart. Bake the recipe’s mkdir + atcd run lines into your container
entrypoint or init script so they re-run on every device start. Skip that and
the first push after a reboot fails with an unreachable daemon.
Part 1 — Migrating from Mender
Concept map
| Mender | atc equivalent |
|---|---|
| Mender Server (hosted or self-hosted) | None needed — the controller talks straight to devices over SSH |
| mender-authd / mender-updated daemons | atcd (one daemon; installed by atc bootstrap) |
| Device authentication (auth sets, JWT, preauthorization) | Your SSH keys. Provisioning a device = installing your deploy key; no accept/reject queue |
Artifact (.mender file) | A push from an atc.toml manifest — bundles and/or OCI images. No artifact file to build, sign, or upload; content is chunked and sent directly |
Artifact name (e.g. release-v2) | Version label at push time: --version v2 (or -t name@v2) |
| Artifact provides/depends | Not needed — the daemon’s store is content-addressed; delta and compatibility bookkeeping is automatic |
| Device type compatibility | Not enforced by atc — target the right devices from your rollout list (and build per-arch OCI images with platform =) |
Update modules: file, directory, deb | A bundle (ship the tree; a hook runs dpkg -i or whatever the module did) |
Update modules: docker, docker-compose “application” | An [[oci]] artifact + a commit hook that loads/restarts the container |
Update module: rootfs-image | No equivalent today. Keep your OS pipeline, and read Where the OS tier stands |
| State scripts | Lifecycle hooks (mapping below) |
| Deployments, static/dynamic groups, phased rollout | A loop over your device inventory in CI; phases/canaries are your loop’s logic |
Standalone mode (mender-update install) | The normal atc model — push without any server |
| Delta updates (commercial, xdelta3, read-only rootfs required) | Built-in automatic delta transfer, no preconditions, no license |
| Remote terminal / file transfer / port forward add-ons | SSH / scp / atc shell |
| RBAC, tenants, audit (Enterprise) | Your CI’s access control — pushes come from wherever you run atc |
Step by step
1. Leave the disk alone. A mender-converted or meta-mender-built device
keeps its A/B partitions, bootloader logic, and /data partition after Mender
is removed. You don’t un-convert a disk in the field, and you don’t need to.
atc installs alongside all of it and ignores it. Your currently-booted rootfs
just becomes “the OS”.
2. Install atc and bootstrap the daemon (onboarding.md
steps 1–2). The bootstrap line below needs systemd as PID 1. Check each device
type once with ssh deploy@device 'ps -p 1 -o comm='. It prints systemd when
bootstrap applies. Any other output means you skip bootstrap and take the
foreground recipe in “No systemd as PID 1” below
instead:
curl -fsSL https://atc.dev/install.sh | sh
export PATH="$HOME/.local/bin:$PATH" # installer drops atc here
atc bootstrap atcd install --yes --non-interactive deploy@device
atc ota status deploy@device # handshake = migrated device is reachable
You are most likely porting this into CI, so note that the export above
covers one shell only. A non-interactive runner sources neither ~/.bashrc nor
~/.profile. Set PATH in each step or call the binary by full path
($HOME/.local/bin/atc). See
onboarding.md for the details.
No systemd as PID 1
On a device that doesn’t run systemd as PID 1, bootstrap stops with
ATC-E0419 before it changes anything — it installs atcd as a systemd unit
and there is no unit to install. The error prints the full foreground recipe;
it’s also in
deploy-your-first-update.md.
Start atcd without sudo, as the user you SSH in as, or the next push
fails with ATC-E0420 on a root-owned socket. A root daemon you cannot avoid
takes --socket-owner <login-user> instead. That flag hands the socket to that
one account. Coming from Mender, see the tmpfs caveat under
Device requirements above. The runtime dir doesn’t
survive a restart, so the recipe belongs in your entrypoint.
3. Repackage what your artifacts carried. For each payload type you ship
today, declare an artifact in atc.toml:
[[bundle]]
name = "app"
source = "dist" # what your directory/file update module carried
[[oci]]
name = "worker"
source = "registry.example.com/team/worker:aarch64" # what your docker module carried
platform = "linux/arm64"
There is no version = field and no artifact-name baked into the build —
the version label is assigned when you push.
4. Translate state scripts to lifecycle hooks. Mender’s
<State>_<Enter|Leave|Error> scripts map almost one-to-one onto atc’s
[<scope>.lifecycle.hook.<state>.<phase>] tables, where <scope> is the
artifact type the hook hangs off — bundle for a [[bundle]], oci for an
[[oci]] (full reference: atc-toml.md):
| Mender state script | atc hook |
|---|---|
Download_Enter_* / Download_Leave_* | download.enter / download.exit |
ArtifactInstall_Enter_* / _Leave_* | install.enter / install.exit |
| stopping the outgoing version (Mender leaves this to you) | deactivate.enter / deactivate.exit |
ArtifactCommit_Enter_* (the gate before the switch) | activate.enter |
ArtifactCommit_Leave_* (bring the new version into service) | activate.exit |
| reporting once the delivery is durable | commit.exit |
ArtifactRollback_* | rollback.enter / rollback.exit |
ArtifactFailure_* | failure.enter / failure.exit |
*_Error_* | No equivalent. No state dispatches an error gate, and <state>.error is rejected at push. Report from <state>.exit, or from the unwind hooks rollback.* / failure.* |
| cleaning up as an old version is reclaimed | uninstall.enter / uninstall.exit |
Idle_* / Sync_* (root-fs scripts) | No equivalent. There is no polling daemon state, so run these outside atc (cron, systemd timers) |
atc splits Mender’s ArtifactCommit into three states, because the version
pointer flips partway through: activate.enter runs before it,
deactivate.* tears the outgoing version down, then the pointer flips, then
activate.exit and commit.* run after it. commit.enter and commit.exit
both still exist and both still run; they just both sit after the flip now. The
mapping above puts each script where its Mender counterpart sat relative to the
switch. Details in the hook cutover runbook.
One asymmetry to plan around: deactivate.* and uninstall.* are read from the
declaration of the version being torn down or deleted, not the one arriving. So
they take effect one delivery later than you write them.
The table names the state and phase, not the whole key. Keep the .hook.
segment: [bundle.lifecycle.hook.commit.exit], never
[bundle.lifecycle.commit.exit]. The body key is script, not run. Both
mistakes are rejected at push time with the correct shape printed — see
atc-toml.md.
Worked example. A Mender ArtifactCommit_Leave script that brought the new
version into service and reported it becomes an activate.exit hook. Declare it
on the artifact, shipped inside the bundle:
[[bundle]]
name = "app"
source = "dist"
[bundle.lifecycle.hook.activate.exit] # first gate after the version flips
script = "@self/scripts/report.sh"
timeout_secs = 120
Mender handed your script discrete MENDER_* variables. atc clears the
environment and passes the whole delivery context as one JSON variable, so the
translation is mostly “read the fields with awk”:
#!/usr/bin/env bash
set -euo pipefail
# Hooks run with a CLEARED environment: no discrete ATC_* scalars, just this one
# JSON document (plus a pinned PATH). Parse it with awk, not jq or python3. A
# device image does not always carry them, and a missing reader gives an EMPTY
# value rather than an error.
#
# `ctx` reads one dotted field. A second argument gives the value to use when
# the field, or an object above it, is null. With no second argument a null
# field exits nonzero, and a field under a null object prints "".
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
name=$(ctx artifact.name)
new=$(ctx version.new_sha)
old=$(ctx version.old_sha "") # null on a first install
sudo -n systemctl restart "$name" # needs the sudoers rule below
curl -fsS -X POST https://fleet.example.com/deployed \
--data-urlencode "artifact=$name" --data-urlencode "from=$old" --data-urlencode "to=$new"
Mender scripts ran as root; atc hooks do not. bootstrap creates an
atc-hooks user and runs hooks as it, so a bare systemctl restart fails on
authorization. Because activate.exit is veto-capable, that failed command
rolls the delivery back. Grant the one action the hook needs:
# /etc/sudoers.d/atc-hooks-restart (install with visudo -f, mode 0440)
atc-hooks ALL=(root) NOPASSWD: /usr/bin/systemctl restart app
sudo -n never prompts, so a missing rule fails fast and visibly instead of
hanging the delivery. Which user a hook gets depends on the bootstrap path.
onboarding.md covers both.
Every tool a hook calls must already be on the device. The pinned PATH gives
the hook only what the image installed. That is why the script reads its context
with awk, which every base image carries, busybox included. A minimal image
such as debian:stable-slim has no python3.
jq is absent from many images.
A hook that calls a missing jq gets an EMPTY value instead of an error, so it
reports success over a step that did nothing. A hook that
exits nonzero on activate or commit is a veto: atcd rolls the whole delivery
back and reports the push as failed. That is why the script above fails loudly
instead of reporting nothing. For the field-by-field context reference, see
onboarding.md and
atc-toml.md.
Differences that will bite if unplanned:
- Hooks are shipped inside the artifact (
script = "@self/scripts/x.sh"), like Mender’s artifact-embedded scripts — there is no/etc/mender/scriptsequivalent for root-fs-resident hooks (absolute paths are allowed but sit outside atcd’s rollback guarantee). - Hooks run with a cleared environment; the delivery context arrives as
one JSON variable,
ATC_HOOK_CONTEXT(parse withawk), see atc-toml.md. - There is no exit-21 retry protocol. A non-zero exit fails the state (and a
failed delivery is reverted, firing your
rollback/failurehooks).
5. Rework CI. Replace mender-artifact write … + the Management API
upload/deploy calls with a push per device:
# was: mender-artifact write module-image … && curl …/deployments/artifacts && curl …/deployments/deployments
atc oci import registry.example.com/team/worker:aarch64 # only for [[oci]] artifacts
while read -r device; do
# bare push = every declared artifact (the default); add `--only <name>` to narrow.
atc ota push --version "v${CI_TAG}" --switch --non-interactive "deploy@${device}"
done < devices.txt
--non-interactive (key-only SSH) and --retries N are the automation knobs;
--json gives machine-readable results for your rollout dashboard. Phased
rollout = split devices.txt into waves and gate each wave on health checks —
the logic you previously configured in a deployment, now in code you control.
Reading the push result in a script. The default Push Results table draws
box characters and lays its columns out against the terminal width, so two
pushes of one artifact can print differently shaped rows. Do not parse it. Two
surfaces are stable instead.
--json prints one final-summary document. Use it when the caller has a parser.
--summary-lines prints one plain line per artifact, plus a totals line. Use it
when the caller has no parser, or when a human reads the CI log:
artifact name=robot-app kind=bundle ref=8f3a1c… version=v1.2.0 bytes_sent=1468006 bytes_reused=860112 status=activated
artifact name=vision kind=oci ref=44ab09… version=- bytes_sent=0 bytes_reused=44000000 status=staged
push artifacts=2 bytes_wire=1468006 retries=0 max_retries=5
Four rules hold that shape, so awk '$1 == "artifact"' keeps working.
- Field one is the record type. Every field after it is
key=value. - The keys are the same for both kinds, and every key is always present. An OCI
artifact carries no version label, so it prints
version=-. - No value holds a space.
- A size is a raw byte count, never
1.4 MB.
status is one of three tokens: activated, staged, or already-present.
kind is the artifact’s real kind, so it reads bundle, oci, module, or a
module-defined spelling such as podman.web-img. Those land in different trees
on the device, so a script that picks a path must read it.
--json wins when both flags are given.
--summary-lines also suppresses the switch prompt and the connect prose, the
way --json does, so a step with a TTY cannot block on a question. It renders
the push RESULT. --dry-run previews instead of pushing and keeps its own
output.
One outcome of atc ota push is not a push at all. When the command finds no
atc.toml above the working directory, it writes a starter manifest and stops.
A first-time operator then gets a file to edit instead of an error. That outcome
carries its own record type. No artifact line and no push line come with it:
scaffold path=/srv/ci/work/atc.toml status=written
So read field one before you read a value. A run that prints scaffold pushed
nothing. The answer is a manifest, not a retry.
6. Understand the rollback difference. Mender’s safety net is the
bootloader: a rootfs that can’t boot falls back automatically. Today atc does
not own your bootloader, so its equivalent guarantees are:
- a failed delivery (hook failure, interrupted transfer) is reverted automatically — the previous version stays active;
- a bad-but-installed version is rolled back with one command, using bytes already on the device:
atc ota ls deploy@device # see what's on the device
atc ota switch deploy@device v1.4.2 # host first, then version
What atc cannot save you from today is an update that breaks the device’s SSH
reachability itself. That’s the app’s job not to do. systemd watchdogs and
Restart= policies on your services are the right tool, and they ship in your
bundle.
7. Decommission Mender. Once a device is atc-managed and verified:
ssh deploy@device 'sudo systemctl disable --now mender-updated mender-authd mender-connect 2>/dev/null'
then decommission the device in the Mender UI/API so it stops counting toward your plan. Uninstalling the client package is optional; the partition layout stays either way. Run both systems in parallel per device for as long as you like — they don’t conflict — but a device taking updates from two masters is confusing, so disable Mender’s updater at cutover.
Mender migration checklist
- Inventory which update modules / payload types you actually ship
- Devices SSH-reachable with key auth + passwordless sudo
-
atc bootstrap(or the no-systemd recipe) on a pilot device -
atc.tomlwith a bundle/OCI artifact per payload type - State scripts ported to
@selflifecycle hooks (ATC_HOOK_CONTEXT, read withawk) - CI: artifact build + API deploy replaced with
atc ota pushloop - Rollback drill: push a version,
atc ota switchback, verify - Mender daemons disabled at cutover; device decommissioned server-side
Part 2 — Migrating from balena
Concept map
| balena | atc equivalent |
|---|---|
| balenaCloud / openBalena backend | None — controller → device over SSH |
| balenaOS | Replaced by a stock Linux (see “the host OS decision” below) |
| Supervisor (on-device agent) | atcd — but atcd only delivers and flips versions; running containers is your init system’s job |
| balena-engine | docker or podman, installed by you on the new OS |
| Fleet | Your device inventory list (CI loops over it) |
| Release | A pushed version across the artifacts in atc.toml (delivery-pinned mode flips them together) |
| Service (container in compose) | An [[oci]] artifact + a systemd unit (or compose file) shipped in a bundle |
balena push (cloud builders) | Build images in your own CI (docker buildx for multi-arch), then atc oci import + atc ota push |
| Delta updates | Built-in automatic delta transfer on every push |
Update strategies (io.balena.update.strategy) | Lifecycle hooks — you script the swap, so any strategy is expressible |
| Update locks | Not needed — push model: nothing updates until you push, and --no-switch stages without activating |
| Track latest / release pinning | Explicit per-device pushes; “pinning” is just not pushing |
| Rollback (pin to earlier release, re-download) | atc ota switch — instant, uses bytes already on the device |
| Device/fleet/service variables | Env files shipped in a bundle (versioned with the release) |
Cloudlink VPN, balena device ssh, tunnel | Your own overlay network (WireGuard/Tailscale) + plain SSH |
| Public device URLs | Not provided — expose services with your own ingress |
| Local mode / livepush | Ordinary local docker development; push to a bench device with atc ota push |
| Supervisor API (reboot, restart, purge) | SSH + systemd (atc shell user@device -- systemctl --user restart app) |
The host OS decision
This is the real migration cost, so decide it first: balenaOS is the host OS, and its supervisor/engine are not separable from it. Moving to atc means re-provisioning each device with a general-purpose Linux you control. Pick Debian, Ubuntu, or Yocto, and include systemd, a container runtime (docker or podman), and your SSH deploy key. That’s a reflash, or a fleet-refresh cycle where new hardware ships with the new image.
Keeping that OS current afterwards is your pipeline’s job, exactly as it is for a Mender migrant. Where the OS tier stands covers why, and what is changing.
What you get back for that cost: a device that is a normal Linux box — any tool runs on it, no compose-subset restrictions, no supervisor between you and your containers, and an update system with no cloud dependency and no per-device pricing.
Build the new base image once (golden image with runtime + key + optionally a
pre-installed atcd), and the per-device step is flash → boot →
atc ota push.
Step by step
1. Build the replacement base image (your distro tooling of choice). Include: systemd, docker/podman, your deploy user + SSH key, passwordless sudo for that user, and your overlay-network agent if devices aren’t directly reachable. Then bootstrap:
atc bootstrap atcd install --yes --non-interactive deploy@device
2. De-balenafy the compose file. Two categories of change:
io.balena.features.*labels each stand for a host bind-mount or credential injection — replace with the explicit equivalent (io.balena.features.dbus→ mount/run/dbus;kernel-modules→ mount/lib/modules;balena-socket→ mount your runtime’s socket;supervisor-api→ gone, see step 5). Standard compose has no restrictions here —bindmounts,env_file,container_nameall work again.Dockerfile.template(%%BALENA_MACHINE_NAME%%,%%BALENA_ARCH%%) → a plain Dockerfile on standard multi-arch base images, built per-arch withdocker buildxin your CI (balena itself has deprecated balenalib images in favor of standard ones, so this may be done already).
3. Declare the release in atc.toml. One [[oci]] per service, plus one
bundle carrying the compose file / systemd units and the hooks that swap
containers:
[[oci]]
name = "web"
source = "registry.example.com/acme/web:aarch64"
platform = "linux/arm64"
[[oci]]
name = "worker"
source = "registry.example.com/acme/worker:aarch64"
platform = "linux/arm64"
[[bundle]]
name = "stack" # compose.yaml + scripts/ — the "release config"
source = "deploy/stack"
[bundle.lifecycle.hook.activate.exit] # first gate after the flip: swap containers
script = "@self/scripts/restart-stack.sh"
timeout_secs = 300
In the default delivery-pinned mode a --switch push flips all artifacts
to the new version atomically — that’s your “release”: images and config move
together, like a balena release, and roll back together.
4. Write the swap hook — this is your update strategy. atcd delivers image
bytes into its store; the channel’s artifact store is a standard OCI image
layout at /var/atc/channel/local/default/artifacts/oci, tagged
<name>:<version> with the version label you pushed. Loading images into your
runtime and restarting services is an activate.exit hook on the stack bundle
(have your CI write the release version into an env file inside the bundle, the
same way a balena release pins image tags). activate.exit is the first gate
after the version pointer flips, so the window where the device points at the
new release but is still running the old containers is as short as it can be:
#!/usr/bin/env bash
set -euo pipefail
# This bundle's new version dir, read with awk (jq and python3 are not on
# every image). The helper is the `ctx` from the hook example above.
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
here=$(ctx self_root.new)
[ -n "$here" ] || { echo "stack: self_root.new is null" >&2; exit 1; }
source "$here/release.env" # VERSION=v2.0.0, written by CI
oci_root=/var/atc/channel/local/default/artifacts/oci
for svc in web worker; do
skopeo copy "oci:${oci_root}:${svc}:${VERSION}" \
"containers-storage:localhost/${svc}:${VERSION}" # docker-daemon:… for docker
done
podman-compose -f "$here/compose.yaml" up -d # compose pins ${VERSION} tags
(skopeo goes in your base image; standard OCI tooling reads the store
directly — no registry, no tarball.)
Because you write the swap, every balena strategy is expressible:
download-then-kill is the default shape above (bytes are fully staged before the
hook runs). Kill-then-download is a download.enter hook that stops services.
Stop-at-the-last-moment is a deactivate.exit hook, which runs after every
fallible step and immediately before the pointer flips. Hand-over is starting the
new container before stopping the old one in your script. rollback/failure
hooks give you the recovery path.
5. Replace supervisor-API consumers. Containers that called
/v1/update, restart-service, or update locks were coordinating with an
autonomous updater. Under a push model most of that disappears:
- Update locks / maintenance windows: push with
--no-switchany time, thenatc ota switchinside the window — or simply schedule the push. The device never updates itself. - Restart/reboot from inside a container: mount the docker socket or use a host-side systemd path — it’s a normal Linux box now.
6. Export cloud state before you leave. Fleet/device/service variables
live only in balenaCloud — pull them via the API and re-home them as env files
in your stack bundle (secrets go to your secret manager, injected in CI at
push time). Same for anything encoded in BALENA_HOST_CONFIG_* vars: those
become settings baked into your base image (config.txt, kernel args).
7. Cut over per device. Reflash with your base image, bootstrap, push,
verify (atc ota status, service health), then delete the device from the
balena fleet so it stops counting toward your plan. There is no parallel-run
option on one device (balenaOS can’t host atcd’s world) — the pilot phase is a
subset of devices, not a subset of each device.
balena migration checklist
- Base image built: stock Linux + systemd + docker/podman + deploy key (+ overlay network)
- Compose file de-balenafied (labels → explicit mounts; templates → buildx multi-arch)
- Fleet/device/service variables exported from the balena API and re-homed
-
atc.toml: one[[oci]]per service + astackbundle with compose/units - Swap hook written (
activate.exit), rollback drill done - CI:
buildx→ registry →atc oci import→atc ota pushloop over the device list - Pilot devices reflashed and verified; balena devices deleted at cutover
Further reading
- onboarding.md — install
atcand ship a first update - deploy-your-first-update.md — includes the no-systemd / container device recipe and a troubleshooting table
- atc-toml.md — full manifest + lifecycle-hook reference
(
ATC_HOOK_CONTEXTschema) - lifecycle-hook-cutover.md — which gate each hook
belongs at, now that the version pointer flips inside
activate - verifying-the-binary.md — the release trust chain
docs/development/error-codes.md— anyATC-Exxxxyou hit along the way