Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  • atc is 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.
  • atcd is the on-device daemon. It owns a content-addressed store at /var/atc and 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.toml manifest.
  • 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:

ConcernMender / balena answerWith atc
Host OS / full rootfs updatesMender A/B partitions; balena HUPNot 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 connectivitybalena Cloudlink VPN; Mender websocket add-onsSSH 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 URLsAdd-ons / built-inPlain SSH (and atc shell for a hook-identity shell on bootstrapped devices). No HTTP ingress to devices.
Cloud-held device variablesbalena fleet/device variables; Mender ConfigureConfig 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 inventoryWeb UIsCLI: atc ota status, atc ota ls, --json for machines.
Autonomous update agentDevices poll and self-updatePush 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

Menderatc equivalent
Mender Server (hosted or self-hosted)None needed — the controller talks straight to devices over SSH
mender-authd / mender-updated daemonsatcd (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/dependsNot needed — the daemon’s store is content-addressed; delta and compatibility bookkeeping is automatic
Device type compatibilityNot enforced by atc — target the right devices from your rollout list (and build per-arch OCI images with platform =)
Update modules: file, directory, debA 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-imageNo equivalent today. Keep your OS pipeline, and read Where the OS tier stands
State scriptsLifecycle hooks (mapping below)
Deployments, static/dynamic groups, phased rolloutA 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-onsSSH / 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 scriptatc 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 durablecommit.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 reclaimeduninstall.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/scripts equivalent 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 with awk), 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/failure hooks).

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.toml with a bundle/OCI artifact per payload type
  • State scripts ported to @self lifecycle hooks (ATC_HOOK_CONTEXT, read with awk)
  • CI: artifact build + API deploy replaced with atc ota push loop
  • Rollback drill: push a version, atc ota switch back, verify
  • Mender daemons disabled at cutover; device decommissioned server-side

Part 2 — Migrating from balena

Concept map

balenaatc equivalent
balenaCloud / openBalena backendNone — controller → device over SSH
balenaOSReplaced 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-enginedocker or podman, installed by you on the new OS
FleetYour device inventory list (CI loops over it)
ReleaseA 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 updatesBuilt-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 locksNot needed — push model: nothing updates until you push, and --no-switch stages without activating
Track latest / release pinningExplicit 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 variablesEnv files shipped in a bundle (versioned with the release)
Cloudlink VPN, balena device ssh, tunnelYour own overlay network (WireGuard/Tailscale) + plain SSH
Public device URLsNot provided — expose services with your own ingress
Local mode / livepushOrdinary 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 — bind mounts, env_file, container_name all work again.
  • Dockerfile.template (%%BALENA_MACHINE_NAME%%, %%BALENA_ARCH%%) → a plain Dockerfile on standard multi-arch base images, built per-arch with docker buildx in 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-switch any time, then atc ota switch inside 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 + a stack bundle with compose/units
  • Swap hook written (activate.exit), rollback drill done
  • CI: buildx → registry → atc oci import → atc ota push loop over the device list
  • Pilot devices reflashed and verified; balena devices deleted at cutover

Further reading