Running OCI containers on incus

While common OCI container runtimes have a much larger ecosystem, environments already using incus for system containers or VMs may prefer to use it as a unified tool for all container workloads.

About OCI support in incus

Incus can pull OCI images and run them as containers just fine, but it does differ from fully compliant OCI runtimes. The differences do not matter for most use cases, but you should be aware of them before choosing incus over docker or podman.


Incus does not preserve container image layers after importing images, instead squashing them into a single directory tree and storing that in the incus image format. It can talk to OCI image registries and pull their images, but is unable to use layer caching, meaning it cannot skip downloading shared layers across images. The conversion to incus format also means there is no way to turn the incus container back into an OCI image. There is no feature equivalent to docker commit and uploading altered OCI containers as images to OCI registries is not supported.


Incus provides only minimal image pulling and container runtimes, omitting anything not strictly necessary to get an OCI container up and running. If you want to build container images from Containerfile/Dockerfile definitions, you will have to reach for tools like buildah, and rely on skopeo for advanced OCI registry interactions.


Container logs are kept in a memory buffer and not written to disk by incus in any way. As a consequence, you can only see a limited amount of most recent messages, and all output is lost when the host restarts.


Lastly, incus structures all instances in projects, a group with optionally limited access to hardware resources, storage and networks. You can choose to ignore them for the most part and use the implicit default project.

Adding OCI registries

Incus has builtin support for standard OCI image registries - as long as you only want to pull images. Since there is no default registry for OCI containers, you should add the public docker hub:

incus remote add docker https://docker.io --protocol=oci

This adds the public docker hub registry under the name docker.


If you need access to images only available when authenticated, you can create a PAT (personal access token) on docker hub and provide that when adding the registry:

incus remote add docker-authed https://docker.io \
  --protocol=oci \
  --token 'your_pat_here'

Note that the --token flag is unreliable, because it uses an OAuth2 token refresh API endpoint that is not part of the original container registry standard.

If you are self-hosting a container registry like the ones built into gitea or gitlab, chances are the --token authentication will not work for you.


In that case, you can always fall back to the basicauth mechanism actually mandated by the registry standard, which is supported everywhere:

incus remote add my-gitea \
  https://username:pat_token_here@my-gitea.com \
  --protocol=oci

Even though basicauth works for every registry, you should prefer --token where possible, since the basicauth credentials will be visible when listing available registries with incus remote ls, whereas --token remains hidden from such commands.


Be aware that the username:PAT@ addition before the registry URL depends on the registry you are using. For harbor, it is your username and password, whereas gitea will ignore the username and expect a PAT as the password instead. Other systems may require the PAT name or a static string like "token" in place of the username. Check the documentation of your specific registry, implementations vary a lot.

Basic container operation

Assuming you have set up the public docker registry from the previous step, you can now launch containers from it:

incus launch docker:nginx my-http-server

This starts a new container my-http-server from the nginx image.


You can also start a temporary interactive container instead (similar to docker run --rm -it bash:

incus launch docker:bash --ephemeral --console

After exiting the interactive container, it is automatically removed.


To view the logs of an existing container, add the --show-log flag to the incus console command:

incus console --show-log my-http-server

Note that logs are only stored in a limited memory buffer, losing older messages as output grows, or output on host restarts.


Running a command in an existing container is done using incus exec:

incus exec my-http-server -- bash

**Note the -- separating the incus command and possible flags from the command you want to run inside the my-http-server container.


Finally, stop and remove the container:

incus stop my-http-server
incus rm my-http-server

If you want to stop and remove a container at the same time, you can force remove it to save time:

incus rm -f my-http-server

This may exit the container uncleanly, but since you want to remove it anyway, that's likely not a problem.

Viewing container state

You can view all running instances:

incus ls

The output will list incus instances regardless of type, including system containers and virtual machines:

+-----------------+---------+---------------------+------------------------------------------------+-----------------+-----------+
|      NAME       |  STATE  |        IPV4         |                      IPV6                      |      TYPE       | SNAPSHOTS |
+-----------------+---------+---------------------+------------------------------------------------+-----------------+-----------+
| my-http-server  | RUNNING | 10.98.76.205 (eth0) | fd42:aee8:b3c5:f567:1266:6aff:fe2b:c7db (eth0) | CONTAINER (APP) | 0         |
+-----------------+---------+---------------------+------------------------------------------------+-----------------+-----------+
| opensuse        | STOPPED |                     |                                                | CONTAINER       | 0         |
+-----------------+---------+---------------------+------------------------------------------------+-----------------+-----------+
| rockylinux      | STOPPED |                     |                                                | CONTAINER       | 0         |
+-----------------+---------+---------------------+------------------------------------------------+-----------------+-----------+

To get only OCI containers, you have to filter the output:

incus ls | grep 'CONTAINER (APP)'

Be aware that you will lose the table column header names in this case:

| my-http-server | RUNNING | 10.98.76.205 (eth0) | fd42:aee8:b3c5:f567:1266:6aff:fe2b:c7db (eth0) | CONTAINER (APP) | 0         |

For a live view of resource consumption:

incus top

Output:

+----------------+-------------+----------+------+
| INSTANCE NAME  | CPU TIME(s) |  MEMORY  | DISK |
+----------------+-------------+----------+------+
| my-http-server | 0.04        | 10.51MiB |      |
+----------------+-------------+----------+------+
Press 'd' + ENTER to change delay
Press 's' + ENTER to change sorting method
Press CTRL-C to exit

Delay: 10s
Sorting Method: Alphabetical

Detailed information about a container's configuration and state

incus info my-http-server

Output may look similar to:

Name: my-http-server
Description: 
Status: RUNNING
Type: container (application)
Architecture: x86_64
PID: 499737
Created: 2026/08/19 08:06 CEST
Last Used: 2026/08/19 08:06 CEST
Started: 2026/08/19 08:06 CEST

Resources:
  Processes: 14
  Disk usage:
    root: 584.00KiB
  CPU usage:
    CPU usage (in seconds): 0
  Memory usage:
    Memory (current): 26.32MiB
  Network usage:
    eth0:
      Type: broadcast
      State: UP
      Host interface: veth9fce21b4
      MAC address: 10:66:6a:2b:c7:db
      MTU: 1500
      Bytes received: 3.88kB
      Bytes sent: 1.98kB
      Packets received: 31
      Packets sent: 17
      IP addresses:
        inet:  10.98.76.205/24 (global)
        inet6: fd42:aee8:b3c5:f567:1266:6aff:fe2b:c7db/64 (global)
        inet6: fe80::1266:6aff:fe2b:c7db/64 (link)
    lo:
      Type: loopback
      State: UP
      MTU: 65536
      Bytes received: 0B
      Bytes sent: 0B
      Packets received: 0
      Packets sent: 0
      IP addresses:
        inet:  127.0.0.1/8 (local)
        inet6: ::1/128 (local)

This is similar to docker inspect, although a lot shorter and notably missing event information like OOM kill and restart counters.

Configuring containers

Most configuration is abstracted into a simple key=value format in incus, including environment variables, resource limits and OCI specific settings.


You can either set them when launching an instance:

incus launch docker:sample-image -c environment.MY_VAR=test

or add them to an existing container:

incus config set my-container environment.FOO=bar

Note that changing environment variables may require a container restart to take effect.


Limits work the exact same way:

incus config set my-container \
  limits.cpu=4 \
  limits.memory=2GiB

The limits category contains a lot more options like disk iops or cpu scheduling quotas. Note that limiting container/volume storage is treated as a device property, not instance configuration.


There are also a few config parameters specific to OCI containers:

incus config set my-container \
  oci.entrypoint=/myapp/run.sh \
  oci.cmd=run.sh \
  oci.cwd=/myapp \
  oci.uid=0 \
  oci.gid=0

Check the incus instance configuration documentation for a full list of available options.

Accessing files

Copying files between containers and the host is supported out of the box in three different ways. The easiest is to simply mount an entire container directory onto the host:

incus file mount my-container/path/in/container /path/on/host

Or alternatively edit a single file in the container:

incus file edit my-container/path/in/container

The launched editor is chosen by system defaults and can be overridden with the EDITOR environment variable.


To instead copy files between them without mounting:

# pull from container to host
incus file pull -pr my-container/path/in/container /path/on/host
# push from host into container
incus file push -pr my-container/path/in/container /path/on/host

Note the addition of -p to automatically create missing directories and -r to support copying directories recursively.


Lastly, incus provides more fine-grained container filesystem access using file create and file remove, but you very rarely need them.

Adjusting devices

Network interfaces, port forwarding and storage are all treated as virtual devices. This may be unfamiliar to you if you are coming from docker, since incus treats container root filesystems, storage volumes and host bind mounts all as disk devices.


Device configuration can get fairly complex, so we only go over the most common use cases for OCI containers here.


Limit container filesystem size:

incus config device set my-container root size=10GiB

Bind-mount directory from host into container, optionally readonly:

incus config device add my-container my-bind-mount disk \
   path=/path/in/container \
   source=/path/on/host \
   readonly=true

Create and attach a storage volume to a container:

incus storage volume create default my-volume size=1GiB
incus config device add my-container my-volume-mount disk \
  path=/path/in/container \
  pool=default \
  source=my-volume

Port-forward an HTTP application from container port :80 to host port :8080:

incus config device add my-container http proxy \
  listen=tcp:0.0.0.0:8080 \
  connect=tcp:127.0.0.1:80

All of these commands create and attach devices to the container. You can view the entire configuration with:

incus config show my-container --expanded

Make sure to include the --expanded flag to get complete device information.

To unmount a volume or remove a port forward, simply remove the associated device:

incus config device remove my-container my-device-name

Check the device documentation for a more complete guide on incus device types and options.

Compose projects

If you are already familiar with the docker-compose syntax and ecosystem and don't want to learn about incus-specific instance handling, incus-compose may be all you need.

The incus-compose project is designed as a drop-in replacement for docker-compose, reusing the same familiar commands and syntax.


Note that incus-compose comes with several requirements. It only runs on incus >=7, requires docker or podman to build images and needs the incus HTTP API to be exposed on port 8443.

The last part can be done with a single command:

incus config set core.https_address=:8443

While incus-compose is considered stable and ready for production use, its dependencies make it cumbersome to work with in current operating systems. This is only compounded by the fact that neither incus 7 nor incus-compose are available from the default apt repository for debian 13. You should probably stick to docker-compose or podman-compose for now.

More articles

Understanding the linux tr utility

Mapping characters one by one

A guide to regular expressions on linux

Including BRE and ERE syntax