Elixir client for the Incus REST API (API reference), built on Req.

Covers instances (containers and VMs), snapshots, networks, profiles, images, projects, storage pools/volumes, certificates, server config and background operations. Anything not wrapped yet is reachable through ExIncus.Client.request/4.

Installation

def deps do
  [
    {:ex_incus, "~> 1.0"}
  ]
end

Connecting

# Local Unix socket (default: /var/lib/incus/unix.socket)
client = ExIncus.connect()

# Remote server over HTTPS with TLS client certificate auth
client =
  ExIncus.connect(
    base_url: "https://incus.example.net:8443",
    certfile: "/path/client.crt",
    keyfile: "/path/client.key"
  )

# Scope everything to an Incus project
client = ExIncus.connect(project: "staging")

The client is a plain struct - build it once, share it freely.

Usage

# Create + start a container from images.linuxcontainers.org
{:ok, _} = ExIncus.launch(client, "web1", "debian/12",
  config: %{"limits.memory" => "1GiB"})

# Start / stop / restart
{:ok, _} = ExIncus.stop(client, "web1")
{:ok, _} = ExIncus.start(client, "web1")

# Give an interface a static IP on a managed bridge
{:ok, _} =
  ExIncus.Instances.set_device(client, "web1", "eth0", %{
    "type" => "nic",
    "network" => "incusbr0",
    "ipv4.address" => "10.140.100.10"
  })

# Point the container at our DNS server
{:ok, _} =
  ExIncus.push_file(client, "web1", "/etc/resolv.conf",
    "nameserver 10.140.100.1\n", uid: 0, gid: 0, mode: 0o644)

# Run commands
{:ok, result} = ExIncus.exec(client, "web1", ["ip", "-4", "addr"])
result.exit_code #=> 0
result.stdout    #=> "1: lo: ..."

# Import a locally converted OCI image and (re)point an alias at it
# (equivalent of: incus image import img.tar.gz --alias myapp --reuse)
{:ok, image} = ExIncus.import_image(client, "/tmp/converted.tar.gz",
  alias: "myapp", reuse: true)

# ...then launch from the local alias
{:ok, _} = ExIncus.launch(client, "app1", %{"type" => "image", "alias" => "myapp"})

# Change config (merged, other keys untouched)
{:ok, _} = ExIncus.Instances.update_config(client, "web1", %{"boot.autostart" => true})

# Inspect runtime state (addresses, usage, ...)
{:ok, state} = ExIncus.Instances.state(client, "web1")

# Tear down
{:ok, _} = ExIncus.stop(client, "web1", force: true)
{:ok, _} = ExIncus.delete_instance(client, "web1")

Live events (no polling)

For dashboards and monitors, ExIncus.EventListener subscribes to the /1.0/events websocket and pushes events to your processes. Run one listener per Incus server in your supervision tree:

children = [
  {ExIncus.EventListener,
   name: MyApp.IncusEvents,
   connect: [socket_path: "/var/lib/incus/unix.socket"],
   types: [:lifecycle, :operation]}
]

Then, from any process (a LiveView, say):

{:ok, status} = ExIncus.EventListener.subscribe(MyApp.IncusEvents, types: [:lifecycle])

# messages arriving in handle_info/2:
{:ex_incus_event, %ExIncus.Event{action: "instance-started", name: "web1"}}
{:ex_incus_connected, listener}            # fires on every (re)connect
{:ex_incus_disconnected, listener, reason}

Incus does not replay missed events, so the pattern for a correct live view is: subscribe → snapshot (ExIncus.Instances.list/2) → apply events → on {:ex_incus_connected, _} snapshot again. The listener reconnects itself with backoff.

To bridge into Phoenix.PubSub instead, pass a handler:

{ExIncus.EventListener,
 connect: [socket_path: "/var/lib/incus/unix.socket"],
 handler: fn msg -> Phoenix.PubSub.broadcast(MyApp.PubSub, "incus", msg) end}

Note on "booted" vs "started": instance-started fires when init launches. For a genuine ready signal, have the guest report readiness (a one-liner against /dev/incus/sock inside the container) and watch for the instance-ready lifecycle action.

Conventions

  • Every call takes the client first and returns {:ok, result} or {:error, %ExIncus.Error{}} - never raises.
  • State-changing calls are asynchronous on the Incus side. By default the library waits for the background operation to finish (timeout: seconds, default 300, or :infinity). Pass wait: false to get the ExIncus.Operation back immediately and deal with it yourself via ExIncus.Operations.
  • A command that exits non-zero inside exec is still {:ok, result} - check result.exit_code.

Modules

ModuleCovers
ExIncusshortcuts for the common instance operations
ExIncus.Instanceslifecycle, state, config/devices, exec, files, logs
ExIncus.EventListener / ExIncus.Eventlive event stream over websocket
ExIncus.Snapshotsinstance snapshots incl. restore
ExIncus.Networksnetworks, DHCP leases, state
ExIncus.Profilesprofiles
ExIncus.Imagesimages, tarball import/export, aliases
ExIncus.Projectsprojects
ExIncus.StoragePools / ExIncus.StorageVolumesstorage
ExIncus.Certificatestrust store
ExIncus.Serverserver info/config, hardware resources
ExIncus.Operationslist/get/wait/cancel background operations
ExIncus.Clientconnection handling, raw request/4 escape hatch

Testing

The unit tests stub the HTTP layer with Req.Test and run anywhere (no Incus needed - they pass on macOS):

mix test

Integration tests exercise a real Incus server and are excluded by default. On a machine with Incus:

mix test --only integration

Configured via environment variables:

VariableMeaningDefault
EX_INCUS_TEST_SOCKETUnix socket path/var/lib/incus/unix.socket
EX_INCUS_TEST_URLremote https://host:8443 (takes precedence)-
EX_INCUS_TEST_CERTFILE / EX_INCUS_TEST_KEYFILEclient cert for remote-
EX_INCUS_TEST_IMAGEimage alias to launchalpine/3.21

They create instances named ex-incus-test-* and clean up after themselves. Note the socket is usually owned by root/incus-admin, so run as a user in the incus-admin group.

Not (yet) implemented

  • Interactive/streaming exec over websockets (exec uses record-output instead, which suits scripted use)
  • Split (metadata + rootfs) image tarballs - unified tarballs only
  • Cluster management endpoints
  • ETag / If-Match concurrency control (pass headers: [{"if-match", etag}] yourself where needed)

License

Copyright 2026 Nippy Networks

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.