incus-compose supports building local service images from Compose build:
definitions and importing the result into the Incus project.
Build support requires
podmanordockeron the machine running incus-compose.
incus-compose does not implement a builder itself and does not use the Buildah Go library. It shells out to a local container builder, then imports the built rootfs into Incus as an image.
Builder selection:
INCUS_COMPOSE_BUILDER, when setbuildah, when found in PATHpodman, when found in PATHdocker, when found in PATHExamples:
INCUS_COMPOSE_BUILDER=podman incus-compose build
INCUS_COMPOSE_BUILDER=docker incus-compose up --build
If no builder is found, build-configured services fail with an error.
Build all services that define build::
incus-compose build
Build selected services:
incus-compose build web worker
Start services, building missing build-configured images as needed:
incus-compose up
Force rebuild before starting:
incus-compose up --build
Require built images to already exist:
incus-compose up --no-build
Short syntax:
services:
web:
build: .
Object syntax with an explicit image name:
services:
web:
image: localhost/web:latest
build:
context: .
dockerfile: Containerfile
When image: is omitted, incus-compose uses a local image name based on the
project and service:
localhost/<project>-<service>
| Option | Support |
|---|---|
context |
Build context directory. Relative paths are resolved by compose-go. |
dockerfile |
Alternate Dockerfile or Containerfile path, resolved relative to context (absolute paths are used as given). |
dockerfile_inline |
Inline Dockerfile content. incus-compose writes it to a temporary file before invoking the builder. |
args |
Build arguments, passed as --build-arg KEY=VALUE. Args without values are ignored. |
no_cache |
Passed as --no-cache to the builder; also skips the shared image cache for this build (see Image Caching). |
pull |
Passed as --pull. |
target |
Multi-stage build target, passed as --target. |
platforms |
A single platform is supported. Multiple platforms are rejected. |
service platform |
Used as the build platform when build.platforms is not set. |
By default, a built image is imported into the shared image-cache project first
(the incus-compose-cache project, or whatever --image-cache /
INCUS_COMPOSE_IMAGE_CACHE points at) and then copied from there into the
compose project, the same path pulled images take.
flowchart LR
B[builder<br/>or registry] -->|only on a cache miss| C[(image cache<br/>shared)]
C -->|copy| P1[project A]
C -->|copy| P2[project B]
C -->|copy| P3[project C]
The cache is checked before the builder runs. If it already holds the
image's alias, nothing is built and nothing is pulled - the image is copied
straight from the cache into your project. So the first up anywhere builds,
and every project after that copies.
That is what makes "build once, use many" work with build: left in place, and
it is also what lets a machine that cannot build at all - no podman, docker
or buildah, which is common on Windows and macOS - consume an image someone
else built, as long as it is in the cache.
Since: v1.2.0-rc.2
A built image is stored in the cache under its Incus alias, which comes from the service's image name and nothing else:
| Compose | Cache alias |
|---|---|
image: ghcr.io/me/app:v1 |
ghcr.io/me/app:v1 |
image: myapp:latest |
docker.io/library/myapp:latest |
no image:, service web |
local/web:latest |
Nothing else feeds the key - not the project name, not the build context, not the Dockerfile. Two builds that resolve to the same image name are the same cache entry, whichever project or compose file they came from, and the last build to finish wins for all of them.
So the image name is the knob: set image: explicitly on every service that
builds, and give services that build different content different names. The
localhost/<service> fallback has no project prefix, so relying on it means two
projects that both have a service called web share one entry.
:::warning Because a cache hit skips the builder entirely, editing your
Dockerfile or build context does not trigger a rebuild on its own - the
image name is unchanged, so the cached image still matches. Use --build to
force one, or bump the tag in image:. This mirrors docker compose, where an
existing image is reused until you pass --build. :::
Set no_cache: true on the service's build: block to skip the shared cache
and import straight into the project instead. The service then rebuilds in every
project, which is also how you avoid sharing a cache entry with a same-named
build elsewhere:
services:
web:
build:
context: .
no_cache: true
With no cache configured at all (--image-cache ""), every build imports
directly into the project, same as no_cache: true.
Since: v1.1.0
Nothing special is needed. Keep the build: block where it is, give the service
an explicit image: name, and every project that uses that name gets the cached
image:
services:
myapp:
image: ghcr.io/example/myapp:v1
build:
context: .
The first up builds and populates the cache. Every later up - same project
or another one, same machine or another one against the same Incus - finds the
alias and copies it. The image name is the whole contract.
A consumer that only wants to use the image can drop the build: block
entirely:
services:
web:
image: ghcr.io/example/myapp:v1
Both forms hit the same cache entry. Dropping build: is worth doing when the
consumer has no access to the build context, or when you want up to fail
loudly rather than build if the image is somehow missing. This is how the
ic-healthd sidecar image is distributed in this repo.
Because a machine only builds on a cache miss, a client with no local
buildah/podman/docker - common on Windows and macOS - can run either form
as long as someone has seeded the cache.
Rebuild under a new tag (:v2) when the content changes rather than overwriting
an existing one. Consumers already holding a project copy of :v1 will not pick
up an in-place replacement, and --build only forces a rebuild for whoever runs
it.
Since: v1.1.0
Built images must match an architecture supported by the target Incus server.
incus-compose asks Incus for its supported server architectures and uses the first one as the default build target. This is not a compose key: it is the list Incus reports. For example, if the server reports:
x86_64, i686
incus-compose builds with:
--platform linux/amd64
and imports the image with Incus metadata architecture:
x86_64
Supported architecture mappings include:
| Incus architecture | Builder platform |
|---|---|
x86_64 |
linux/amd64 |
i686 |
linux/386 |
aarch64 |
linux/arm64 |
armv7, armv7l |
linux/arm/v7 |
armv6, armv6l |
linux/arm/v6 |
ppc64le |
linux/ppc64le |
s390x |
linux/s390x |
riscv64 |
linux/riscv64 |
If a service requests a platform that Incus does not report as supported, the build fails before invoking the builder.
incus-compose build [SERVICE...]
| Option | Description |
|---|---|
--no-cache |
Disable the builder's layer cache for this build, and skip the shared image cache (see Image Caching). Also enabled when build.no_cache: true is set. |
--pull |
Pull policy for the images this build depends on: always, missing/policy, never. Base-image freshness is separate - set build.pull: true in the compose file, which is passed to the builder as its own --pull. |
For build-configured services, up builds only when the image is missing from
both the compose project and the shared image cache - see
Image Caching.
flowchart TD
S([up]) --> B{"service has build:?"}
B -->|no| PULL[the pull path]
B -->|yes| NB{--no-build?}
NB -->|yes| MISS{image present anywhere?}
MISS -->|no| FAIL([fail])
MISS -->|yes| USE([use it])
NB -->|no| FB{--build?}
FB -->|yes| BUILD[run the builder]
FB -->|no| INP{already in the project?}
INP -->|yes| USE
INP -->|no| INC{alias in the shared cache?}
INC -->|yes| COPY[copy cache to project]
INC -->|no| BUILD
BUILD --> IMP["import into the cache,<br/>unless no_cache: true"]
IMP --> COPY
COPY --> USE
| Command | Behavior |
|---|---|
incus-compose up |
Build only on a cache miss. Copy from the cache when the alias is already there. |
incus-compose up --build |
Force rebuild, replacing the cached image, and recreate the instances that use it. |
incus-compose up --no-build |
Never build. Fail if a required built image is missing. |
In practice: the first up anywhere builds, and every up after that - in the
same project or a different one - copies from the cache. --build is how you
pick up changes to your Dockerfile or context.
An instance is created from an image, so replacing the image leaves the running
instance on the old one. --build therefore deletes and recreates the instances
of every service whose image it rebuilt - including a service that only
consumes an image another service builds, since the rebuild replaces its image
just the same:
services:
app:
image: localhost/app:latest
build:
context: .
worker:
image: localhost/app:latest # recreated as well
db:
image: docker.io/postgres:16-alpine # left alone
Nothing else is touched: services without a built image keep running, and so do
volumes, networks and the ic-healthd sidecar. Naming services (up --build app)
narrows it further, to the built services in that scope. --recreate is the
bigger hammer, recreating the whole project whether it was built or not.
Changed in v1.2.0: --build used to rebuild the image and leave the instances
on the old one until --recreate was passed too.
The following Compose build options are currently not implemented:
additional_contextscache_fromcache_toentitlementsextra_hostsisolationlabelsnetworkprivilegedprovenancesbomsecretsshm_sizesshtagsulimitstags are intentionally ignored for now. incus-compose imports the built
artifact into Incus and uses the Incus image alias needed by the project; extra
Docker-style tags do not affect runtime behavior.
build command flags and up build
behavior