Compose Compatibility

incus-compose implements a subset of the Compose Specification. This doc lists what works and what doesn't.

Supported Features

Incus Override File

If a compose.incus.yaml file exists next to the selected compose.yaml, incus-compose loads it automatically as an additional Compose file. Use it for Incus-specific overrides while keeping the upstream Docker Compose file unchanged.

compose.yaml
compose.incus.yaml

Example compose.incus.yaml:

services:
  web:
    ports: !reset []
    x-incus:
      limits.memory: 512MiB

networks:
  default:
    x-incus:
      ipv4.address: 10.100.0.2/24
      ipv4.gateway: 10.100.0.1

Running with the base file also applies the Incus override when present:

incus-compose -f compose.yaml up

The override file follows normal Compose merge rules. For example, !reset [] clears a list from the base file.

Services

Labels

Compose labels are stored on the instance as user.label.<key> config keys. Both the map and list forms work:

services:
  app:
    image: docker.io/nginx:alpine
    labels:
      caddy: whoami.example.com
      caddy.reverse_proxy: "{{upstreams 80}}"
  api:
    image: docker.io/nginx:alpine
    labels:
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"

becomes:

config:
  user.label.caddy: whoami.example.com
  user.label.caddy.reverse_proxy: "{{upstreams 80}}"
  user.label.traefik.http.routers.api.rule: "Host(`api.example.com`)"

Two labels are always added:

Key Value
user.label.incus-compose.project the compose project name
user.label.incus-compose.service the compose service name

Read them back with the incus passthrough:

incus-compose incus config get app-1 user.label.caddy

Service discovery - the user.label. prefix keeps compose labels out of the user.* namespace incus-compose uses for its own keys, and mirrors the label conventions of reverse proxies and DNS managers:

None of these tools support incus-compose yet: they discover services over the Docker socket, not the Incus API. incus-compose only exposes the labels as user.label.* instance config; consuming them needs an Incus-aware discovery integration.

Changed in 1.0.0-rc.2: labels moved from user.<key> to user.label.<key>, and the incus-compose.project / incus-compose.service labels were added.

User

The user attribute overrides the user the container process runs as, mapping to the image's oci.uid / oci.gid:

services:
  web:
    image: docker.io/nginx:alpine
    user: "1000:1001" # UID:GID; the GID is optional

incus-compose accepts only numeric values in UID or UID:GID form. Usernames and group names (e.g. nginx or nginx:www-data) are not resolved and will fail.

The Compose Specification only says user "overrides the user used to run the container process" and does not document a value format. The UID:GID form is Docker's convention; we follow it but restrict it to numeric IDs because there is no image passwd/group lookup at translation time.

Since: 1.0.0-beta.22

Entrypoint and Command

entrypoint: behaves as the compose spec describes: it replaces the image's entrypoint, and the image's default command is discarded, so the container runs exactly entrypoint: followed by command:.

services:
  web:
    image: docker.io/library/busybox:glibc
    entrypoint: ["httpd", "-f", "-v", "-p", "8080", "-h", "/www"]
entrypoint: command: The container runs
set unset entrypoint
set set entrypoint + command
set [] entrypoint
[] set command
[] unset rejected - nothing to run
unset set image entrypoint + command

command: on its own is appended, not substituted. That last row is the one place incus-compose deviates from Docker, and it is a limitation of the Incus API rather than a choice: Incus derives an OCI container's entrypoint from the runtime bundle's resolved arguments, which already have the image's ENTRYPOINT and CMD concatenated, and never exposes the two separately. Without that split there is no way to replace CMD while keeping ENTRYPOINT.

So an image with ENTRYPOINT ["caddy"] and CMD ["run"] plus command: ["version"] runs caddy run version, where Docker would run caddy version. Set entrypoint: when you need the command to be exactly what you wrote - it takes the image out of the equation entirely.

lxc/incus#3765 is the upstream proposal to expose the split. If it lands, command: on its own will substitute like Docker does, which will be a breaking change for anyone relying on today's append.

Since: v1.2.0

DNS

dns, dns_search, and domainname map to Incus's oci.dns.* instance config keys, which seed the container's initial /etc/resolv.conf:

services:
  web:
    image: docker.io/nginx:alpine
    dns:
      - 8.8.8.8
      - 1.1.1.1
    dns_search:
      - example.com
    domainname: example.com

becomes:

config:
  oci.dns.nameservers: 8.8.8.8,1.1.1.1
  oci.dns.search: example.com
  oci.dns.domain: example.com

Each key is only set when the corresponding compose field is non-empty. dns_opt has no Incus equivalent and is not mapped.

Since: v1.1.0

Sysctls

sysctls sets kernel parameters on the instance, mapping each key to linux.sysctl.<key>. Both the map and list forms work:

services:
  vpn:
    image: docker.io/nginx:alpine
    sysctls:
      net.ipv4.conf.all.src_valid_mark: 1
      net.ipv6.conf.all.disable_ipv6: 0
  web:
    image: docker.io/nginx:alpine
    sysctls:
      - net.core.somaxconn=1024

becomes:

config:
  linux.sysctl.net.ipv4.conf.all.src_valid_mark: "1"
  linux.sysctl.net.ipv6.conf.all.disable_ipv6: "0"
  linux.sysctl.net.core.somaxconn: "1024"

The value applies when the instance starts and survives a restart, on both privileged and unprivileged containers. Which parameters are writable from inside an unprivileged container is Incus's business, not ours: a key the kernel refuses in that namespace fails at start rather than being ignored.

Since: v1.2.0

x-incus Instance Extensions

Any Incus instance config key can be set via the x-incus extension block on a service definition. Keys are passed verbatim to the Incus instance config on creation.

services:
  web:
    image: docker.io/nginx:alpine
    x-incus:
      limits.memory: 512MiB
      limits.cpu: "2"
      security.privileged: "true"

Any Incus instance option is accepted.

x-incus-compose Devices

Attach raw Incus devices to a service's instances with the x-incus-compose.devices block. Each named entry is passed to Incus verbatim; the type key selects the device type and is required.

services:
  web:
    image: docker.io/nginx:alpine
    x-incus-compose:
      devices:
        gpu0:
          type: gpu
          gputype: physical
          pci: "0000:01:00.0"
        extra-disk:
          type: disk
          source: /dev/sdb
          path: /mnt/data

This is an escape hatch for device types incus-compose does not model natively (gpu, unix-char, usb, ...). Compose-managed devices (ports, volumes, networks) should use their native keys. Any Incus device is accepted; keys collide by device name, so a raw device sharing a name with a compose-managed one overrides it.

Since 1.0.0-beta.22

Projects

x-incus:
  limits.cpu: "4"
  limits.memory: 2049MiB # +1 MiB
  limits.virtual-machines: 0

services:
  web:
    image: docker.io/nginx:alpine
    deploy:
      replicas: 4
    x-incus:
      limits.cpu: "1"
      limits.memory: 512MiB

Any Project option is accepted.

x-incus-compose Healthd

Configure the ic-healthd sidecar with the top-level x-incus-compose.healthd extension:

x-incus-compose:
  healthd:
    scope: global
    incus: https://:8443
    network: :default
    workers: 128
    restart-workers: 32
    x-incus:
      limits.cpu: 2
      limits.memory: 256MiB

services:
  web:
    image: docker.io/nginx:alpine
Key Description
scope global (one shared daemon in the Incus incus-compose project, the default) or project (a sidecar of this project's own). Loses to a scope the Incus project already carries.
incus The Incus API URL healthd connects to. Defaults to the bridge gateway and the connection's port.
network <project>:<network> for a managed network, or a plain bridge name. Defaults to the bridge of the project the daemon runs in.
workers Health checks the daemon runs at once, over every project it watches. Default 128.
restart-workers Restarts it runs at once, over every project it watches. Default 32.
x-incus Raw Incus instance config for the sidecar, e.g. limits.*.
external Use a healthd you run yourself; incus-compose neither creates nor looks one up.

scope, incus and network are also --healthd-scope, --healthd-incus and --healthd-network on the CLI, which override the compose file. See Health Checking - Scope and Network Configuration.

With scope: global the daemon is shared, so the first project to bring it up supplies incus, workers, restart-workers and x-incus; a later project asking for something different is warned and ignored.

When this option is set, incus-compose does not create compose-managed Incus network resources for service network attachments. Instances use the network devices provided by the copied profile instead. Service-level static IP assignments (ipv4_address / ipv6_address) are not supported in this mode because incus-compose does not create explicit NIC devices.

Networks

Not supported:

x-incus Network Extensions

Any Incus network config key can be set via the x-incus extension block on a network definition. Keys are passed verbatim to the Incus network config on creation.

networks:
  backend:
    x-incus:
      ipv4.address: 10.100.0.1/24
      ipv6.address: fd42:abc::1/64
      ipv4.dhcp.ranges: 10.100.0.100-10.100.0.200

Any Incus bridge network option is accepted.

External Networks

Mark a network as external: true to attach services to a pre-existing Incus network. incus-compose will never create or delete an external network.

networks:
  shared:
    external: true

Set name: when the Incus network is not called what the compose file calls it. A bare value is an Incus network name, taken literally — use it for a bridge you manage yourself:

networks:
  shared:
    external: true
    name: alpha:dns # the "dns" network of the "alpha" compose project

The reference goes through the same naming rules the owning project used, so it keeps resolving after a rename to a hash — alpha:dns becomes alpha-dns, and a pair long enough to exceed the interface limit becomes the same ic- hash on both sides. Only the project that declares the network creates it; everyone else is external: true.

Name resolution — incus-compose probes the following candidates in order and uses the first one that exists in Incus:

  1. name: value — literal, only when it names no project
  2. name: value — resolved ({project}-{network}, or its hash)
  3. Compose network name — raw
  4. Compose network name — sanitized

If none of the candidates match an existing network, up fails with a not-found error.

Since: v1.2.0

Automatic DHCP Ranges

When a managed bridge network is created, incus-compose automatically configures DHCP ranges if they are not already set:

IPv4 - The first quarter of the address block is reserved for static assignment. The DHCP range starts at that boundary:

Subnet Static range DHCP range
/24 .1-.63 .64-.254
/16 .0.0-.63.255 .64.0-.255.254
/28 .1-.3 .4-.14

IPv6 - The first 256 addresses (::0-::ff) are reserved for static; DHCP runs from ::100 to ::ffff. Stateful DHCPv6 (ipv6.dhcp.stateful) is enabled automatically.

Setting ipv4.dhcp.ranges or ipv6.dhcp.ranges in x-incus disables auto-calculation for that protocol. Existing networks (already present in Incus when up runs) are never modified.

Static IP Assignment

A service can be assigned a fixed IP on a specific network using the standard Compose ipv4_address / ipv6_address fields on the per-service network attachment:

:::warning An address without a netmask (e.g. 10.100.0.2 instead of 10.100.0.2/24) is invalid and fails silently. :::

services:
  db:
    image: docker.io/postgres:16-alpine
    networks:
      backend:

  web:
    image: docker.io/nginx:alpine
    depends_on:
      db: service_healthy
    networks:
      backend:
      frontend:
        ipv4_address: 10.100.0.2/24
        ipv6_address: fd42:abc::2/64

networks:
  frontend:
    x-incus:
      ipv4.address: "10.0.0.1/24"
      ipv6.address: "fd42:abc::1/64"

  backend:
    internal: true

The address is set as ipv4.address / ipv6.address on the Incus NIC device. The bridge's built-in DHCP server reserves it so the instance always receives that address on the network.

The address must fall within the static zone (first quarter of the block) to avoid conflicts with DHCP-assigned addresses.

Setting internal: true on a network disables its gateway by setting ipv4.gateway and ipv6.gateway to none. This requires Incus 7.3 or later (or the 7.0.2 LTS point release). Override this per-service with x-incus-compose.internal: false.

internal: true since: v1.1.0

Network Aliases

The standard Compose aliases field on a service's network attachment registers extra DNS names for that instance:

services:
  db:
    image: docker.io/postgres:16-alpine
    container_name: my-db
    networks:
      default:
        aliases:
          - db.mydomain.lan

Each alias becomes a cname=<alias>,<instance> record in the network's raw.dnsmasq, resolving straight to the instance, with no DHCP lease to wait for, unlike the IP-based service-name records described in DNS Resolution. Aliases on networks shared by multiple projects (external: true / name:) coexist without clobbering each other's records, the same way service-name records do.

:::warning Because a CNAME alias can only point at one target, aliases is for single-instance services. Declaring it on a service with more than one replica registers the same alias against every replica's instance name, which dnsmasq does not support (an alias must be unique) and produces undefined DNS behavior. Use the service name, which does round-robin, for scaled services instead. :::

Since: v1.1.0

Volumes

Not supported:

x-incus Volume Extensions

Any Incus storage volume config key can be set via the x-incus extension block on a volume definition. Keys are passed verbatim to the Incus volume config on creation.

volumes:
  data:
    x-incus:
      size: 10GiB
      block.filesystem: ext4

Any Incus storage volume option is accepted.

The x-incus block also works inline on a volume entry, which is the only way to set options on a bind mount (a bind's source is a path, not a named volume):

services:
  web:
    volumes:
      - type: bind
        source: ./html
        target: /usr/share/nginx/html
        x-incus:
          security.shifted: "false"

An inline x-incus block takes precedence over the matching named volume definition. See Volume Permissions for security.shifted.

x-incus-compose Volume Pool

Set x-incus-compose.pool on a named volume to place it in a specific Incus storage pool. Without this the client's default storage pool is used.

volumes:
  data:
    x-incus-compose:
      pool: fast-ssd

services:
  app:
    image: docker.io/myapp:latest
    volumes:
      - data:/var/lib/app

To move an existing volume to a different pool, stop the project, then use incus storage volume move via the incus-compose incus passthrough:

incus-compose stop
incus-compose incus storage volume move default/vol-library ext/vol-library
incus-compose start

Then update x-incus-compose.pool in your compose file and run incus-compose up --recreate to reattach.

Volumes are stored with a vol- prefix. Long names are hashed, so my-very-long-volume-name may become vol-a1b2c3d4.... Use incus storage volume list to find the actual name before moving:

incus-compose incus storage volume list default

x-incus-compose Volume Seeding

A bind mount is normally passed through: Incus attaches a disk device and incusd resolves the source path on its own filesystem, so the files have to be on the server. Set x-incus-compose.seed: true inline on the volume entry to copy the source instead, which is how a bind mount works against a server that is not your machine:

services:
  web:
    image: docker.io/library/busybox:glibc
    volumes:
      - type: bind
        source: ./html
        target: /www
        read_only: true
        x-incus-compose:
          seed: true

The source is read by incus-compose, on the machine you run it from, and must exist there. What happens next depends on what it is:

Seeding is a copy, in one direction. Nothing written inside the container comes back out, so it does not replace a named volume for data you mean to keep.

Seeding is off by default: bind mounts are plain pass-through unless you ask.

Since: v1.0.0

Environment

Project

Build

See Builds for supported options, builder selection, and platform handling.

Health Checks

Supported via the ic-healthd sidecar. See Health Checking for full details, including config keys, defaults, security model, and healthd management commands.

The healthcheck status (starting, healthy, unhealthy) is reported in the Status column of incus-compose list and incus-compose ps when healthchecks are configured.

Resource Limits

deploy.resources is not mapped. Use x-incus to set Incus instance limits directly:

services:
  app:
    x-incus:
      limits.cpu: "1"
      limits.memory: 512MiB

Any Incus instance config key is accepted. See Architecture for full details.

Restart Policies

Restart policies map to Incus boot configuration:

Compose restart Incus Config
no (default) boot.autostart=false
always boot.autostart=true
on-failure boot.autostart=true, boot.autorestart=true
unless-stopped Uses last-state behavior (Incus default)
services:
  app:
    image: docker.io/nginx:alpine
    restart: always

Restart enforcement is handled by the ic-healthd sidecar, including restart without a healthcheck - see Health Checking.

Secrets

Configs

configs:
  app_config:
    file: ./app_config.txt

services:
  app:
    configs:
      - app_config
      - source: app_config
        target: /etc/app/config.txt
        uid: "1000"
        gid: "1000"
        mode: 0o440

Overwriting Image Files

Configs and secrets are written into the instance before it first starts, and they replace a file the image already ships at that target. This is how you override an application's own default config:

services:
  web:
    image: docker.io/library/caddy:2-alpine
    configs:
      - source: caddyfile
        target: /etc/caddy/Caddyfile

configs:
  caddyfile:
    file: ./Caddyfile

Docker achieves the same by mounting over the path, so the image file is only hidden for the container's lifetime. incus-compose writes into the instance's root filesystem instead, so the replacement is permanent for that instance - the original is gone until the instance is recreated.

Changed in 1.2.0: a target that already existed in the image was previously left untouched, which silently ignored the config or secret.

Not Supported (Yet)

External Secrets and Configs

secrets[].external and configs[].external are not supported.

In Docker Swarm, external: true means "this secret/config already exists: don't create it, just reference it by name." You'd pre-create it once (e.g. docker secret create db_password ./password.txt), and any number of stacks/services could then point at that same object, so rotating it means updating the one external secret rather than every compose file that uses it.

incus-compose has no equivalent standalone "secret" or "config" resource in Incus to reference: it only knows how to read a file, inline content, or an environment variable and push the result into a container as a file. There's nothing in Incus for external to point at, so it's not a missing mapping to fill in later, it's a concept without a target. Use file, content (configs only), or environment instead.

Dockerfile HEALTHCHECK

The HEALTHCHECK instruction embedded in Docker images is not read, so declare healthcheck.test explicitly in the compose file. See healthd.md for the background.

Extended Features

Not supported:

Local vs Remote Incus

The Incus server must have core.https_address set 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, up fails with The 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
flowchart LR
    subgraph L["local - unix socket"]
        direction TB
        CU[client] --> BM["bind mounts: supported"]
        CU --> SET["health checks: automatic only if<br/>core.https_address names a host"]
    end

    subgraph R["remote - HTTPS"]
        direction TB
        CH[client] --> BM2["bind mounts: pass-through only if<br/>incusd is this same machine,<br/>else seed or a named volume"]
        CH --> HC["health checks: automatic,<br/>core.https_address or the bridge IP"]
    end

    L --> D["incusd<br/>needs core.https_address<br/>either way"]
    R --> D

The line for bind mounts is not the transport, it is which machine holds the files. A pass-through bind is a disk device whose source incusd opens on its own filesystem, so the path has to be on the server. Over HTTPS to the machine you are sitting at (a local-https remote, say), that is still true and bind mounts work normally.

Talking to a server somewhere else, incus-compose refuses a pass-through bind with not on the same host rather than handing incusd a path it will not find. The check compares the remote's address against your own interfaces, so it also refuses a different machine that happens to have the same directory layout, even though incusd could have resolved it.

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.

Behavioral Differences

Images

Registries:

Image names work just like Docker: a bare nginx:alpine resolves to docker.io/library/nginx:alpine, and an explicit registry prefix (ghcr.io/...) is honored as-is.

docker.io, ghcr.io, quay.io, mcr.microsoft.com, registry.gitlab.com and codeberg.org need no setup. Any other registry has to be an Incus remote, and adding one of the six above overrides its built-in address — which is how you point at a pull-through cache:

incus remote add --protocol oci registry.example.com https://registry.example.com
incus remote add --protocol oci docker.io https://docker-mirror.example.com
# Both work, identical to Docker Compose
image: nginx:alpine # resolves to docker.io/library/nginx:alpine
image: ghcr.io/myorg/app:v1 # explicit registry

Global cache:

Like Docker, images are cached globally. An image pulled for one project is available to all projects. This avoids duplicate downloads.

Registry authentication:

Docker uses ~/.docker/config.json. Incus uses remote configuration:

incus remote add --protocol oci docker.io https://docker.io --auth-type bearer

See Incus documentation for details.

Platform selection:

Docker allows --platform linux/amd64. incus-compose uses the host architecture automatically. Multi-arch images select the correct variant.

Port Publishing

Docker Compose:

ports:
  - "8080:80" # iptables NAT rule

incus-compose:

ports:
  - "8080:80" # Incus proxy device
flowchart LR
    HOST[host port 8080] --> D{"x-incus-compose.nat?"}
    D -->|"false, the default"| PX["Incus proxy device<br/>a userspace Go process<br/>per forwarded connection"]
    D -->|true| NAT["nftables DNAT rule<br/>kernel mode, Incus 7.2 or later"]
    PX --> C[container port 80]
    NAT --> C

    LO["127.0.0.1 on the host"] --> PX
    LO -.->|"not reachable"| NAT

Both work the same from outside. By default incus-compose uses userspace proxy devices (a Go process per forwarded connection). For high-throughput services you can opt in to kernel-mode NAT via a service extension, which installs nftables DNAT rules instead:

services:
  web:
    image: docker.io/nginx:alpine
    ports:
      - published: "8081"
        target: "80"
        x-incus-compose:
          nat: true
    networks:
      - frontend

nat: true requires Incus 7.2 or later (or the 7.0.1 LTS point release) for ARP/NDP-based instance IP detection. Combining nat: true with a static instance IP additionally requires Incus 7.3 or later (or the 7.0.2 LTS point release).

Warning: with nat: true, published ports are not reachable via localhost/127.0.0.1 on the host running incus-compose. The nftables DNAT rules only masquerade traffic for the hairpin case (an instance reaching itself via its own forwarded address); host-loopback traffic keeps its 127.0.0.1 source address, which is dropped or fails to route back. Use the host's real (LAN/bridge) address to reach the port, or stick with the default userspace proxy if you need localhost access to work.

Since: v1.1.0

Network Naming

Docker Compose:

{project}_{network}  # e.g., myapp_frontend

incus-compose:

{project}-{network}  # e.g., myapp-frontend (if ≤13 chars)
ic-{hash}            # e.g., ic-a1b2c3d4e5 (if >13 chars)

Network names are limited to 13 chars for dhclient compatibility.

Volume Permissions

Docker Compose:

incus-compose:

Disabling shifting (security.shifted: "false"):

Shifting maps host files to the container's UID/GID so they appear correctly owned. Set security.shifted: "false" via x-incus to turn it off, e.g. for a read-only bind mount you don't want re-owned. Without shifting, the host file keeps its raw host UID/GID inside the container, which for an unprivileged container outside the idmap range shows up as nobody (65534):

services:
  web:
    volumes:
      - type: bind
        source: ./html
        target: /usr/share/nginx/html
        read_only: true
        x-incus:
          security.shifted: "false"
$ ls -ln /usr/share/nginx/html/index.html
-rw-r--r-- 1 65534 65534 18 ... index.html

For a bind mount this must be set inline on the volume entry (see x-incus Volume Extensions).

External Volumes

Docker Compose: an external volume must already exist. Compose will never create it, and never removes it (not even with down --volumes / equivalent), since it doesn't own the volume's lifecycle.

incus-compose: every named volume, external or not, goes through the same get-or-create path: reuse the Incus storage volume if it already exists, create it if it doesn't. There's no tracking of "this one was pre-existing." Concretely, that means:

If you need to reference a real pre-existing Incus storage volume without risking it being deleted, avoid down --volumes for that project, or manage the volume directly with incus storage volume outside of compose.

Instance Naming

Instances are named {service}-{index} where index starts at 1:

services:
  web:
    image: docker.io/nginx:alpine
    deploy:
      replicas: 3

Creates instances: web-1, web-2, web-3

You can also override replicas via CLI:

incus-compose up --scale web=5

--scale applies only to that invocation. Like docker compose up, a plain up reconciles each service back to deploy.replicas in both directions: it recreates instances removed by an earlier --scale and tears down extras added by one. Use --scale (or edit deploy.replicas) to change the persistent count.

DNS Resolution

After up, both the service name and the instance name resolve inside containers:

database    → round-robins across all database instances (A/AAAA records)
database-1  → specific instance (registered by Incus dnsmasq)

This matches Docker Compose behavior. No configuration is required: records are written automatically to the project bridge network's raw.dnsmasq and updated whenever the scale changes.

A service can also register extra DNS names for itself via aliases; see Network Aliases.

Note: Setting raw.dnsmasq on the bridge disables AppArmor for the dnsmasq process (not for containers). dnsmasq still runs as an unprivileged user.

Environment Variables

Docker Compose:

export MY_VAR=value
docker-compose up  # MY_VAR available

incus-compose:

export MY_VAR=value
incus-compose up  # MY_VAR NOT available (security)

Use .env files or --os-env flag for docker-compose compatibility.

Config Output

config --format=yaml is byte-identical to docker compose config. config --format=json deliberately is not.

Docker renders JSON straight from the compose model, and compose-go tags every extension field json:"-" - so docker compose config --format json silently drops every x- block. incus-compose renders JSON through the YAML representation instead, which keeps them:

services:
  web:
    image: docker.io/nginx:alpine
    x-incus:
      limits.cpu: "2"
{
  "services": {
    "web": {
      "image": "docker.io/nginx:alpine",
      "x-incus": { "limits.cpu": "2" }
    }
  }
}

Since x-incus and x-incus-compose carry most of what makes a compose file Incus-specific, dropping them would make the JSON output useless for scripting.

Two consequences of rendering through YAML:

Parse the JSON rather than diffing it against docker compose output.

Since: v1.2.0

Testing Compatibility

To test if your compose file works:

# Validate syntax
incus-compose config --quiet

# Show what will be created
incus-compose config

# Try starting
incus-compose up --no-start

# Check what was created
incus-compose list

Reporting Compatibility Issues

If you find a compose feature that should work but doesn't, please report it with:

  1. Minimal compose.yaml that reproduces the issue
  2. Expected behavior (what docker-compose does)
  3. Actual behavior (what incus-compose does)
  4. Incus version: incus version