incus-compose lets you run your existing compose.yaml files directly on Incus
without Docker.
oci_network_config API, which every
compose network attachment relies onpodman or docker for image building (see Builds)incus-compose requires the Incus server to listen on the network. Set
core.https_address:
incus config set core.https_address=:8443
This is not optional, even for a local Incus reached over the Unix socket.
incus-compose caches images in a separate Incus project and copies each one into
your project on up. That cross-project copy uses Incus pull mode, which needs
the server to be reachable over the network: the same daemon pulls the image
from itself. Without core.https_address set, up fails with
The source server isn't listening on the network, and health checks are
silently skipped.
Only the server setting matters here; the client connection itself can stay on the Unix socket. See Local vs Remote Incus for the handful of behaviours that do depend on how you connect.
Connect the client over HTTPS when Incus runs on another host, or when you use
health checks: the ic-healthd sidecar reaches Incus over HTTPS. By default
healthd uses the project's own network and reaches Incus over that bridge; use
--healthd-network / --healthd-incus if your setup differs, see
Network Configuration.
# Generate and trust a certificate
incus remote generate-certificate
incus config trust add-certificate ~/.config/incus/client.crt
incus remote add local-https <a-ip-of-your-host>
# Switch to local-https as default remote
incus remote switch local-https
incus list --all-projects
incus-compose selects which remote to connect to in this order:
--remote <my-remote> CLI flagINCUS_REMOTE environment variableincus remote switch <my-remote>,
preferred)Setting the default remote with incus remote switch <my-remote> is preferred
because it is perstistent:
# Preferred: switch Incus default remote
incus remote switch my-remote
incus-compose up
# Override for a single command
incus-compose --remote my-remote up
# Or via environment variable
export INCUS_REMOTE=my-remote
If you don't want to listen on all interfaces, set the
INCUS_COMPOSE_HEALTHD_INCUS environment variable or call up with
--healthd-incus; see Network Configuration.
The install script downloads the matching release for your OS/developer and verifies it against the published SHA-256 checksums.
# Into a user-writable directory on your PATH (no sudo and self-update working)
curl -sSfL https://raw.githubusercontent.com/lxc/incus-compose/main/install.sh | sh -s -- -b ~/.local/bin
# Or System-wide into /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/lxc/incus-compose/main/install.sh | sudo sh -s -- -b /usr/local/bin
Pass a release tag as the final argument to pin a version, e.g.
... | sudo sh -s -- -b /usr/local/bin 1.0.0-beta15. Without a tag the latest
release is installed.
Download a prebuilt archive from the Releases Page.
On Windows and MacOS, incus-compose runs as a client that drives a remote Incus host over HTTPS - see Installing on Windows.
# Build from source
git clone https://github.com/lxc/incus-compose
cd incus-compose
cp .env.sample .env
just build
# Or install directly
go install github.com/lxc/incus-compose/cmd/incus-compose@latest
Source builds use
ic-healthd:latestby default which might be out-of-sync use-ldflags="-X github.com/lxc/incus-compose/cmd/incus-compose/version.Version=v1.3.2"to pin a healthd version.
services:
web:
image: docker.io/nginx:alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
app:
image: docker.io/node:20-alpine
working_dir: /app
volumes:
- ./app:/app
command: node server.js
depends_on:
- web
incus-compose up
This will:
flowchart TD
S([incus-compose up]) --> P["create the Incus project,<br/>named after your directory"]
P --> HD{"any healthcheck, restart policy,<br/>or service_healthy dependency?"}
HD -->|yes| SC[start the ic-healthd sidecar]
HD -->|no| IMG
SC --> IMG{"image already in the cache?"}
IMG -->|yes| CP[copy it into the project]
IMG -->|no| PULL["pull from the registry,<br/>store it in the cache"]
PULL --> CP
CP --> NV[create networks and volumes]
NV --> INST["create and start instances<br/>in dependency order"]
INST --> WAIT["wait for service_healthy<br/>dependencies to report healthy"]
If your compose file uses health checks, incus-compose manages the ic-healthd
sidecar automatically. It is transparent during normal use, but it is also a
core component: all healthcheck, restart: and depends_on: service_healthy
behavior is enforced by this sidecar, not by Incus. A working healthd is also
required to bring up a project that has service_healthy dependencies - up
waits for healthd to report them healthy, so a broken healthd makes up hang
and fail (unless you pass --no-healthd). If health, restart, or startup
behavior ever looks wrong, debug healthd first - see Health Checking
and Debugging ic-healthd.
incus-compose list
# View logs from all services
incus-compose logs
# Follow logs in real-time
incus-compose logs -f
# View logs from specific services
incus-compose logs web app
# Stop and remove containers
incus-compose down
# Also remove images used by the services
incus-compose down --images
# Remove the whole project, including volumes and images
incus-compose down --project
compose.incus.yaml is loaded automatically when it exists next to the selected
compose.yaml. This lets you keep an upstream or Docker-focused Compose file
unchanged while adding Incus-specific settings in a separate file.
Typical uses:
ports: !reset []ic-healthdx-incusExample compose.incus.yaml:
services:
web:
ports: !reset []
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost"]
networks:
default:
ipv4_address: 10.131.32.17/24
networks:
default:
x-incus:
ipv4.nat: "true"
ipv4.address: 10.131.32.1/24
The file follows normal
Compose merge rules. For
example, !reset [] clears a list from the base file. See
Compose Compatibility for details.
services:
db:
image: docker.io/postgres:16-alpine
environment:
POSTGRES_PASSWORD: dev123
volumes:
- pgdata:/var/lib/postgresql/data
api:
image: docker.io/myapp/api:latest
depends_on:
- db
environment:
DATABASE_URL: postgres://postgres:dev123@db/myapp
web:
image: docker.io/myapp/frontend:latest
depends_on:
- api
ports:
- "3000:80"
volumes:
pgdata:
Services start in dependency order:
flowchart LR
DB[db] -->|"api depends_on db"| API[api]
API -->|"web depends_on api"| WEB[web]
# .env
DB_PASSWORD=secret123
API_PORT=3000
services:
api:
image: docker.io/myapp/api:latest
environment:
DATABASE_PASSWORD: ${DB_PASSWORD}
ports:
- "${API_PORT}:3000"
Only variables defined in .env are available (not your shell environment).
Incus gives each container a real IP on your network:
$ incus-compose list
KIND NAME INCUSNAME IMAGE STATUS ADDRESSES
image docker.io/nginx:alpine docker.io/library/nginx:alpine Exists
network default ic-ynmt73wxwq Exists
instance web-1 web-1 docker.io/library/nginx:alpine Running 10.149.206.30
You can access containers directly: curl http://10.149.206.30
Published ports use Incus proxy devices (not iptables NAT):
ports:
- "8080:80" # Host 8080 → Container 80
Named volumes are Incus custom storage volumes with automatic UID/GID shifting:
volumes:
data:/app/data # Named volume with proper permissions
./local:/app # Bind mount (incusd must be on this machine)
A bind mount is passed through to incusd, which opens the path on its own
filesystem, so it works when the server is this machine, over the Unix socket or
over HTTPS. Against a server elsewhere, either use a named volume or set
x-incus-compose.seed: true to copy the files across.
Each network becomes an Incus bridge network with deterministic naming:
networks:
frontend:
backend:
Long network names are hashed to fit Linux interface limits (13 chars for dhclient compatibility).
Each compose project gets its own Incus project:
$ incus-compose -p myapp up
# Creates Incus project "myapp"
$ incus-compose -p testing up
# Separate Incus project "testing"
Projects are isolated: separate networks, volumes, and instances.
Images are cached in either the incus-compose-cache project or the project you
set via the INCUS_COMPOSE_IMAGE_CACHE env:
$ incus project list
+---------------------------+--------+----------+-----------------+-----------------+----------+---------------+------------------------------------------+---------+
| NAME | IMAGES | PROFILES | STORAGE VOLUMES | STORAGE BUCKETS | NETWORKS | NETWORK ZONES | DESCRIPTION | USED BY |
+---------------------------+--------+----------+-----------------+-----------------+----------+---------------+------------------------------------------+---------+
| default (current) | YES | YES | YES | YES | YES | YES | Default Incus project | 14 |
+---------------------------+--------+----------+-----------------+-----------------+----------+---------------+------------------------------------------+---------+
| immich | YES | YES | YES | YES | NO | NO | incus-compose: immich | 7 |
+---------------------------+--------+----------+-----------------+-----------------+----------+---------------+------------------------------------------+---------+
This means:
incus-compose down only removes project images, cache persistsFor a technical background about images see architecture/client/image.md
The cache project is created automatically on first use.
The Incus server must have
core.https_addressset in all cases, even for a local Unix-socket client. Image caching copies images between Incus projects using pull mode, which requires the server to be reachable over the network. Without it,upfails withThe source server isn't listening on the network. See Getting Started.
With that in place, a few behaviors still depend on whether incus-compose talks to a local Incus over the Unix socket or to a remote daemon over HTTPS:
| Feature | Local (Unix socket) | Remote (HTTPS) |
|---|---|---|
| Bind mounts | Supported | Pass-through only when incusd is the same machine; otherwise seed |
| Health checks | Auto when core.https_address names a host, else set --healthd-incus |
Auto |
Copy the files across with x-incus-compose.seed and none of this applies: that is what the option is for.
For health checks, ic-healthd reaches Incus over HTTPS. When
core.https_address names a host (10.0.0.5:8443) that address is used,
however you connected. Only a bare :8443 falls back to the bridge IP plus the
port incus-compose connected on, which a Unix socket does not have, so there the
endpoint must be set explicitly. See
Network Configuration.
To switch between local and remote daemons, use
incus remote switch <my-remote> (preferred), pass --remote <my-remote>, or
set INCUS_REMOTE. See Selecting the Remote.