Skip to content

Ship code without rebuilding the image

On the paved path, Edgible ships your container images as whole docker save tarballs to each serving device — there is no registry pull, so there is no layer dedup either. For the most common change of all (you edited a template, a route handler, a stylesheet) that means re-shipping hundreds of megabytes to deliver a few kilobytes of new source.

A code artifact removes the rebuild entirely for that case. You declare a source directory on a compose workload; edgible stack deploy packs it into a content-addressed archive, ships it the same way images ship, pins its digest into the release, and the agent mounts it into your container. The runtime image stops changing.

The boundary is one rule:

  • Dependencies or the Dockerfile changed → image deploy. New packages, a different base image, a changed build step. Only a rebuilt image can honour those.
  • Only source or assets changed → code deploy. Handlers, templates, static files, config bundles. Ship the code.

Code artifacts suit interpreted stacks (Node, Python, PHP, Ruby) and static asset trees, where the runtime image is just “the interpreter plus the dependencies” and rarely moves. They are declared only on compose workloads — docker, managed-process, vm, and pre-existing workloads have no code[] field.

Before — code baked into the image, rebuilt and re-shipped on every change:

app.yml
apiVersion: v3
kind: Application
metadata:
name: web
organization: org_...
spec:
placement:
strategy: serving-device
deviceSelector: { deviceName: my-first }
workloads:
- name: web
type: compose
composeFile: ./compose.yaml
ports:
- { name: http, containerPort: 3000 }
access:
- { type: https, target: { workload: web, port: http } }
compose.yaml
services:
web:
image: myapp:1.4.2 # every code change: rebuild, docker save, re-ship per device
ports:
- "127.0.0.1:3000:3000"

After — a stable runtime image plus a code artifact:

app.yml
workloads:
- name: web
type: compose
composeFile: ./compose.yaml
ports:
- { name: http, containerPort: 3000 }
code:
- name: app
path: ./src # packed by the CLI at deploy; digest pinned on cut
compose.yaml
services:
web:
image: myapp-runtime:1 # runtime + dependencies only; unchanged across code deploys
volumes:
- ${EDGIBLE_CODE_APP}:/app:ro # per-digest host path; a digest change recreates the service
ports:
- "127.0.0.1:3000:3000"

Each entry needs a name and a path. The name is a normal Edgible resource name (lowercase alphanumeric with hyphens, 1–63 characters) and must be unique within the workload. The path is a directory, resolved relative to the stack file (absolute paths work too); if it doesn’t exist or isn’t a directory, the deploy stops before anything ships.

Every entry is exposed to your compose file as EDGIBLE_CODE_<NAME> — the name uppercased with every character outside A–Z0–9 turned into _, so web-assets becomes ${EDGIBLE_CODE_WEB_ASSETS}. The value is an absolute host path to the extracted directory. This is the same injection mechanism storage[] mounts use with EDGIBLE_STORAGE_<NAME>.

One verb, three steps, each of which does nothing when nothing changed:

  1. Pack. Each code[].path is tarred with byte-deterministic normalization — entries sorted by name, mtimes forced to epoch 0, uid/gid 0, .git excluded — and hashed. The determinism is the point: an unchanged tree re-packed on another machine or from a fresh git clone produces the identical digest, so it will not spuriously re-ship.

  2. Ship, only if changed. The archive is delivered over the same per-device ingest session images use. Delivery is idempotent by digest, so an unchanged archive transfers nothing. If delivery fails, the deploy fails — it never cuts a release pinning bytes that didn’t land.

  3. Cut, only if changed. When the resolved digest set and the canonical document both equal the current release, no new version is cut. You’ll see:

    - web (app_...) — unchanged, nothing to deploy

    That makes stack deploy safe to run reflexively.

When a digest does change, the mount source path changes with it — the path contains the digest — so compose recreates exactly the services that mount it. Activation is a container recreate, not a hot reload: expect the same brief per-service downtime an image deploy causes.

Always end a code mount with :ro. The extracted directory is keyed by digest and is treated as immutable: on a redeploy or a rollback the agent reuses a per-digest directory that is already present without re-verifying it. Two consequences of writing into it anyway:

  • your writes vanish the next time the digest changes, and
  • they break the guarantee that a digest names exactly the bytes on disk, so a rollback would serve the mutated tree.

Anything the app needs to keep belongs in a storage[] volume, which is provisioned for persistence and can move with the application. The deploy warns when it spots a code mount without :ro.

These are all non-fatal, printed by stack deploy (and by stack validate and stack diff, which run the same checks without shipping anything). None of them changes the exit code.

WarningWhat it meansWhat to do
the packed tree contains dependency lockfile(s)A package-lock.json, yarn.lock, pnpm-lock.yaml, poetry.lock, Pipfile.lock, uv.lock, Gemfile.lock, composer.lock, Cargo.lock, go.sum, or npm-shrinkwrap.json is inside the packed directory. A code archive can’t install dependencies.If dependencies actually changed, rebuild the runtime image and run a full image redeploy. If they didn’t, the warning is informational — it fires on the lockfile’s presence, not on a change.
the packed tree contains secret-looking file(s)A .env / .env.*, *.pem, *.key, id_rsa, or id_ed25519 is inside the packed directory. Code archives are not a secret transport.Move the value to a secret and reference it with envFrom.
code entry "x" is declared but ${EDGIBLE_CODE_X} is never referencedThe archive will ship, register and pin without mounting anywhere.Add the volume line to your compose file, or drop the entry.
the compose file references ${EDGIBLE_CODE_X} but no code[] entry declares itCompose will interpolate an empty string, which is almost never what you want.Declare the entry, or fix the variable name.
code mount ${EDGIBLE_CODE_X} is not read-onlySee above — writes are lost and the digest no longer describes the directory.Append :ro to the volume.

The declare↔mount checks read your compose file on your machine, so they only run for workloads using composeFile. A workload sourced from a composeArtifact bundle isn’t scanned — those bytes never pass through the CLI.

Nothing special to do. Each code archive’s digest is pinned into the release’s artifact list alongside the image digests, so the signed release commits to the code bytes. edgible application rollback <version> moves the deployment pointer back to an existing release, the agent reads the code digests that release pinned, and — because the previous per-digest directory is usually still on disk — the old code is back without re-shipping anything. Retention keeps a blob alive as long as any device still observes a release referencing it.

Agent 1.2.14 or newer. Older agents have no code[] realization at all — compose would interpolate every ${EDGIBLE_CODE_*} to an empty string and start the workload with a silently broken mount. The control plane refuses the deploy instead, with a message naming the device:

device "my-first" runs agent 1.2.9, but spec.workloads[].code requires agent >= 1.2.14.
Upgrade the agent on this device or remove the gated capability from the declaration.

A device that has never reported its agent version is treated as below the floor. Upgrade the agent on that device, then deploy again.

Agent 1.4.2 or newer on Alpine devices. Agents before 1.4.2 extracted archives with GNU-only tar options; on a device whose system tar is BusyBox (Alpine Linux) every code-artifact realization failed with CONFIG_APPLY_FAILED and tar: unrecognized option: delay-directory-restore. The control plane does not gate on this, so upgrade the agent on Alpine devices before declaring code[] for applications placed on them.

GNU tar on the machine that deploys. The packer needs GNU-only tar flags (--sort=name, --mtime, --owner) for its byte-deterministic archives. It looks for gtar first, then tar, and uses the first one that identifies itself as GNU tar. Linux distributions ship GNU tar as tar. macOS ships bsdtar, which rejects those flags, so on a stock Mac the deploy stops at the pack step with a message naming what it tried and the fix:

Terminal window
brew install gnu-tar

Homebrew installs it as gtar, which the packer picks up automatically — no PATH change needed.

Extraction is bounded. The device refuses an archive with more than 100,000 entries or more than 2 GiB of unpacked file bytes, and refuses any archive containing absolute paths, .. segments, symlinks pointing outside the tree, or entry types other than files, directories, and symlinks. A refused archive is never partially extracted. The CLI applies no size cap of its own, so keep the packed directory to the source and assets your app actually serves — point path at ./src, not at the project root.

Extracted mtimes are extraction time, not the packer’s epoch 0, so Last-Modified and mtime-derived ETags on static assets stay sane.

When the device was assigned after the deploy

Section titled “When the device was assigned after the deploy”

Code archives are pushed by the CLI to the devices assigned at the time of the deploy — the same delivery envelope images have. A device that joins the application afterwards was never pushed to, and the agent fails closed rather than starting a container with a missing mount:

code artifact sha256:… is not available on this device: code entry "app" was never
delivered to this device — run `edgible stack deploy` to re-ship code artifacts to
this device

The remedy is in the message: re-run edgible stack deploy with the new device assigned, which ships the archive to it.

The same window opens briefly on the very first deploy of an application that declares code[]: the application is created, then placement resolves the devices, then the bytes ship, then a follow-up version pins them. Until that lands, realization of the first version fails closed with the error above. edgible stack deploy performs all of it in one run, so you normally only see this if the deploy is interrupted part-way.