Docker Container — Registry Pull
This guide walks you through deploying Docker containers on an Avocado OS device end-to-end. It demonstrates two complementary delivery modes:
-
Build-time bake-in A public Docker image is pulled from Docker Hub during
avocado buildand seeded onto the device's var partition. The device boots with the image already cached locally and serves HTTP without needing registry access at runtime — the device works fully offline. The reference usesdocker.io/peridionick/hello-flask:py311(Python 3.11 + Flask 3.0.3) for this path. -
Runtime swap. Once the device is up, an included
container-swaphelper script pulls a different image and restarts the service in place — no rebuild, no reflash. The reference demonstrates this withdocker.io/peridionick/hello-flask-new:py314(Python 3.14 + Flask 3.1.3) and includes a rollback to the originally-baked image that works fully offline because that image stays cached locally throughout.
By default Avocado OS ships with Python 3.12 (if on release 2024). This reference helps show the flexiblity of Avocado working with Docker containers as well as showing if you need to run other versions of Python.
Following the guide end-to-end, you will:
- Build the runtime image and provision the device with the baked-in
hello-flask:py311container. - Verify the container is running Python 3.11 + Flask 3.0.3 (with the host's userland Python being 3.12 — the runtime version isolation is the load-bearing demo).
- Swap to
hello-flask-new:py314at runtime and confirm the new container is now running Python 3.14 + Flask 3.1.3. - Confirm the swap persists across a reboot.
- Roll back to the originally-baked image with no network access required.
Prerequisites
- macOS 10.12+ or Linux (Ubuntu 22.04+, Fedora 39+)
- Docker Desktop (or a working local Docker daemon) — required by the Avocado SDK to perform the registry pull during build
- The latest version of the Avocado CLI
- A supported Avocado target — any from the Support Matrix. The reference is verified on
raspberrypi4; see Customize for adding others. - Internet access on the build machine and a network-reachable path to the device for the verify step
Phase 1 — Build, deploy, and run the baked-in container
In Phase 1, you'll build the runtime image with hello-flask:py311 (Python 3.11 + Flask 3.0.3) baked into the var partition, provision the device, and verify the container is up and serving HTTP. The device works fully offline at runtime — no registry access required after deploy.
Initialize
avocado init --reference docker-registry docker-registry
cd docker-registry
Install
avocado install -f
Downloads the SDK toolchain and runtime extensions. First-time install fetches several hundred megabytes; subsequent installs are cached.
Build
avocado build
avocado build starts an ephemeral Docker daemon inside the SDK container, pulls docker.io/peridionick/hello-flask:py311 for the target architecture, and seeds the image cache into the var partition. The pull happens at build time only — the device requires no registry access on first boot.
The reference ships a customized stone manifest at stone/<target>/stone-<target>.json that bumps the var partition to 2048 MB so the seeded image fits.
Multi-target note. Only
raspberrypi4ships with a pre-customized stone manifest. Building for a different target requires addingstone/<target>/stone-<target>.json— see Customize → Adjust the var partition size.
Deploy
# Raspberry Pi 4 / 5 (SD card)
avocado provision -r dev --profile sd
For SD-card targets, follow the prompts. For Jetson, follow the USB recovery-mode prompts. After boot, on_merge hooks start docker.service and container-app.service.
Find the device IP
- Serial console:
ip addrafter login.
Substitute <device-ip> in the commands below.
Verify
SSH into the device. The default config extension sets an empty root password for development:
ssh root@<device-ip>
Services are healthy
systemctl status docker.service container-app.service
Both should report Active: active (running).
Image is local (no on-device pull)
docker images
Expected:
REPOSITORY TAG IMAGE ID CREATED SIZE
peridionick/hello-flask py311 abc123def456 X weeks ago ~75MB
Hit the HTTP endpoint
From any machine on the same network as the device:
curl http://<device-ip>:8080
Expect HTML containing the line:
Container Python: 3.11.X (...)
Confirm Python 3.11 inside the container
For direct verification that the container is actually running Python 3.11 (not just claiming it in a response body):
docker exec container-app python --version
# → Python 3.11.X
Side-by-side proof of runtime isolation — same kernel, different Python:
echo "=== HOST ===" && python3 --version && uname -r
echo "=== CONTAINER ===" && docker exec container-app sh -c 'python --version && uname -r'
The host's Python is 3.12; the container's is 3.11. This is the whole point of the reference: pin a specific runtime version inside a container, independent of the host distro, with no registry access at runtime.
For a deeper poke, drop into a shell inside the container (it's Alpine, so sh not bash):
docker exec -it container-app sh
# inside: python --version, pip list, cat /app/app.py, exit
Watch live logs
journalctl -u container-app -f
Each request to :8080 produces a Flask log line. Ctrl+C to stop.
✅ Phase 1 complete. The device is running hello-flask:py311 and serving HTTP on port 8080 — fully offline. You can stop here for a basic deploy, or continue to Phase 2 to demonstrate runtime updates.
Phase 2 — Swap to a different container at runtime
In Phase 2, the device is already running from Phase 1. You'll swap to a different container image at runtime — no rebuild, no reflash, no re-provision. The reference ships a container-swap helper for this; it pulls a target image, updates the active-image env file, and restarts the systemd service in place.
The image used as the swap target is docker.io/peridionick/hello-flask-new:py314 — Python 3.14 + Flask 3.1.3 (one major version up from the Phase 1 baked-in 3.11 + 3.0.3). The device needs internet access for the initial pull. The originally-baked image stays cached on the var partition throughout, so the rollback at the end of this phase works fully offline.
Confirm the swap script is available
Still SSH'd into the device:
which container-swap
# /usr/local/bin/container-swap
container-swap
# Usage:
# container-swap <image:tag> Pull and run a new image.
# container-swap reset Restore the factory default (...)
Swap to the updated image
container-swap docker.io/peridionick/hello-flask-new:py314
Expected output (timing varies with network speed; pull is typically 30–90 seconds):
Pulling docker.io/peridionick/hello-flask-new:py314...
py314: Pulling from peridionick/hello-flask-new
...
Status: Downloaded newer image for docker.io/peridionick/hello-flask-new:py314
Restarting container-app.service...
Now running: docker.io/peridionick/hello-flask-new:py314
Verify the new container is running
docker ps
# CONTAINER ID IMAGE ... NAMES
# abc123def456 docker.io/peridionick/hello-flask-new:py314 ... container-app
The image column should show hello-flask-new, not hello-flask.
docker exec container-app python --version
# → Python 3.14.X (was Python 3.11.X before the swap)
docker exec container-app python -c "import flask; print(flask.__version__)"
# → 3.1.3 (was 3.0.3 before the swap)
From any machine on the LAN, hit the endpoint:
curl http://<device-ip>:8080
The page now contains the UPDATED badge and reports Container Python: 3.14.X and Flask version: 3.1.3. Both the original (Python 3.11 + Flask 3.0.3) and the swap target (Python 3.14 + Flask 3.1.3) are now resident on the device:
docker images
# REPOSITORY TAG ... SIZE
# peridionick/hello-flask-new py314 ... ~75MB
# peridionick/hello-flask py311 ... ~75MB
Confirm the swap persists across reboots
cat /var/lib/container-app/active-image.env
# CONTAINER_IMAGE=docker.io/peridionick/hello-flask-new:py314
reboot
After the device comes back up and you SSH in again:
curl http://<device-ip>:8080 | grep "Container Python"
# Still → Python 3.14.X
Roll back to the originally-baked image
container-swap reset
reset does not pull anything — the original image is still cached locally on the var partition, so this step works even without network access. Expected output:
Resetting to factory default: docker.io/peridionick/hello-flask:py311
Restarting container-app.service...
Now running: docker.io/peridionick/hello-flask:py311
Confirm:
curl http://<device-ip>:8080 | grep "Container Python"
# → Python 3.11.X (back to the original)
docker exec container-app python --version
# → Python 3.11.X
How it works under the hood
- The systemd unit reads
${CONTAINER_IMAGE}from/var/lib/container-app/active-image.env. container-swap <image:tag>runsdocker pull, rewrites that env file, andsystemctl restarts the service.- The env file lives under
/var(not/etc, which is read-only on Avocado), so swaps persist across reboots. container-swap resetrewrites the env file back to the factory default — the same image that was baked into the var partition at build time, so it's always available offline.- To swap to your own image:
container-swap docker.io/<your-org>/<your-image>:<tag>. - Each successful swap leaves the previous image cached locally; you can flip back and forth without re-pulling. Storage is bounded by the var partition (2048 MB in this reference), so very long swap chains will eventually need
docker image pruneto reclaim space.
✅ Phase 2 complete. The device just demonstrated a full runtime-update lifecycle: pulled a new image at runtime, swapped to it, persisted the swap across reboot, and rolled back to the original image without network access.
Debugging
avocado build fails with fwup: file size assertion failed
The var partition cap in the active stone manifest is too small for the seeded Docker image. The reference bumps raspberrypi4 to 2048 MB; this error means either you're on a different target with no per-target manifest, or the cap still isn't large enough. Bump the var partition size in stone/<target>/stone-<target>.json. See Customize → Adjust the var partition size.
avocado build fails with "manifest file not found"
The reference ships a stone manifest only for raspberrypi4. Add stone/<target>/stone-<target>.json for any other target — see Customize → Adjust the var partition size.
avocado build fails during the registry pull
- Local Docker daemon not running. Start Docker Desktop (or
sudo systemctl start docker). - Docker Hub rate-limited. Run
docker loginfrom your shell beforeavocado build; the SDK container reuses your host's credentials. - Proxy or DNS issue. Confirm
curl -I https://registry-1.docker.io/v2/works from your shell.
Container service fails to start on the device
journalctl -u container-app.service -b --no-pager | tail -30
The most useful diagnostic for the container itself:
docker logs container-app
Common causes: image missing locally (re-run avocado build and re-provision), port 8080 already bound (change the -p flag in the systemd unit), kernel module missing for Docker's bridge networking (the docker extension declares kernel-module-bridge, kernel-module-br-netfilter, and kernel-module-veth — keep them if you fork).
Customize
Change the image permanently
For changes that should land in the next provisioned image (rather than only at runtime), edit docker_images in avocado.yaml:
docker_images:
- image: docker.io/<your-org>/<your-image>
tag: <your-tag>
Then update the factory default in two places so the systemd unit and container-swap reset agree:
overlay/app/usr/lib/tmpfiles.d/container-app.conf— the seededCONTAINER_IMAGE=lineoverlay/app/usr/local/bin/container-swap— theDEFAULT_IMAGE=shell variable
Rebuild:
avocado build
avocado provision -r dev
Change the served page
The page content lives inside the Docker image, not in this reference. Edit the image source (Dockerfile + app.py), publish a new tag to the registry, bump the tag in avocado.yaml, then rebuild.
Change the listening port
In overlay/app/etc/systemd/system/container-app.service:
-p <new-host-port>:8080
The :8080 on the right is the container's Flask port; don't change it unless you also edit app.py in the image source.
Adjust the var partition size
The reference ships stone/raspberrypi4/stone-raspberrypi4.json with the var partition at 2048 MB. The relevant block:
{
"name": "var",
"image": "var",
"size": 2048,
"size_unit": "mebibytes",
"expand": "true"
}
Bump size for larger images. expand: "true" means the partition still grows on first boot to fill the SD card, so this value mostly affects build-time allocation.
To support another target, add stone/<target>/stone-<target>.json:
- Pull the BSP's default manifest out of the SDK container:
docker run --rm docker.io/avocadolinux/sdk:2024-edge \cat /opt/avocado-sdk/stone/stone-<target>.json \> stone/<target>/stone-<target>.json
- Edit the
varpartition'ssizeupward. - If you also need a custom
bootfiles/config.txt, drop it atstone/<target>/bootfiles/config.txt. Unmodified files fall through to the BSP defaults.
The avocado.yaml stone_manifest: and stone_include_paths: lines are templated with {{ avocado.target }}, so per-target convention works automatically.
Add Docker daemon configuration
Edit overlay/docker/etc/docker/daemon.json. The default is {}; add daemon-level config (insecure registries, log drivers, etc.) as needed.
Pull from a private registry
Run docker login <registry> from your shell before avocado build. The SDK container reuses your host's Docker credentials.