ExIncus
Copy MarkdownElixir 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"}
]
endConnecting
# 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). Passwait: falseto get theExIncus.Operationback immediately and deal with it yourself viaExIncus.Operations. - A command that exits non-zero inside
execis still{:ok, result}- checkresult.exit_code.
Modules
| Module | Covers |
|---|---|
ExIncus | shortcuts for the common instance operations |
ExIncus.Instances | lifecycle, state, config/devices, exec, files, logs |
ExIncus.EventListener / ExIncus.Event | live event stream over websocket |
ExIncus.Snapshots | instance snapshots incl. restore |
ExIncus.Networks | networks, DHCP leases, state |
ExIncus.Profiles | profiles |
ExIncus.Images | images, tarball import/export, aliases |
ExIncus.Projects | projects |
ExIncus.StoragePools / ExIncus.StorageVolumes | storage |
ExIncus.Certificates | trust store |
ExIncus.Server | server info/config, hardware resources |
ExIncus.Operations | list/get/wait/cancel background operations |
ExIncus.Client | connection 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:
| Variable | Meaning | Default |
|---|---|---|
EX_INCUS_TEST_SOCKET | Unix socket path | /var/lib/incus/unix.socket |
EX_INCUS_TEST_URL | remote https://host:8443 (takes precedence) | - |
EX_INCUS_TEST_CERTFILE / EX_INCUS_TEST_KEYFILE | client cert for remote | - |
EX_INCUS_TEST_IMAGE | image alias to launch | alpine/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 (
execusesrecord-outputinstead, which suits scripted use) - Split (metadata + rootfs) image tarballs - unified tarballs only
- Cluster management endpoints
- ETag /
If-Matchconcurrency control (passheaders: [{"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.0Unless 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.