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.
When to use it
Section titled “When to use it”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.
What you write
Section titled “What you write”Before — code baked into the image, rebuilt and re-shipped on every change:
apiVersion: v3kind: Applicationmetadata: 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 } }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:
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 cutservices: 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>.
What stack deploy does
Section titled “What stack deploy does”One verb, three steps, each of which does nothing when nothing changed:
-
Pack. Each
code[].pathis tarred with byte-deterministic normalization — entries sorted by name, mtimes forced to epoch 0, uid/gid 0,.gitexcluded — and hashed. The determinism is the point: an unchanged tree re-packed on another machine or from a freshgit cloneproduces the identical digest, so it will not spuriously re-ship. -
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.
-
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 deployThat makes
stack deploysafe 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.
Mount read-only, keep state in storage[]
Section titled “Mount read-only, keep state in storage[]”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.
Warnings you’ll see
Section titled “Warnings you’ll see”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.
| Warning | What it means | What 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 referenced | The 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 it | Compose 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-only | See 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.
Rollback
Section titled “Rollback”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.
Requirements and limits
Section titled “Requirements and limits”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:
brew install gnu-tarHomebrew 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 neverdelivered to this device — run `edgible stack deploy` to re-ship code artifacts tothis deviceThe 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.
Related
Section titled “Related”- Deploy a Docker Compose app — the base flow this builds on.
- Application YAML reference — the
code[]fields. - Applications — how releases pin artifact digests.