A production-ready CRUD REST API for managing an LXD
server — instances, storage, networks, projects, and images — built with
FastAPI + Pydantic v2 and talking directly to the LXD REST API over raw
httpx (no pylxd or any LXD SDK).
It supports both ways LXD's own clients connect:
| Mode | Transport | Auth | Use when |
|---|---|---|---|
| local | Unix socket (unix.socket) |
implicit (socket access) | API runs on the LXD host / same pod |
| remote | HTTPS with mutual TLS | client cert + key (trust add) | API runs elsewhere; lxc remote add |
This API's own client auth (JWT) is separate from LXD's mTLS auth, which is used only for the API-to-LXD connection.
- Versioned API — everything under
/api/v1;/healthstays unversioned. - Instances — CRUD, start/stop/restart/freeze/unfreeze, exec & console over WebSocket, state (CPU/mem/IPs), logs, snapshots, backups (with tarball export).
- Storage — pools + volumes CRUD, resize, attach/detach volumes to instances.
- Networks — CRUD, state/leases, NIC attach/detach.
- Projects — CRUD + per-request project scoping (
?project=). - Images — list/get/delete, copy from remote image servers (
ubuntu:,images:). - Async operations — list/get/wait/cancel + a WebSocket relay of LXD's event stream.
- JWT auth + RBAC —
admin>operator>viewerrole hierarchy. - Resilience — structured JSON logging, request IDs, global error handler,
CORS, rate-limited auth endpoints, deep
/healththat checks LXD connectivity.
# 1. Configure
cp .env.example .env
# -> set LXD_CONNECTION_MODE and edit connection details
# 2. Build & run
make up # docker compose up --build -d
# 3. Check it
curl http://localhost:8000/health
# -> {"status":"ok","version":"1.0.0"}
# 4. Open the docs
open http://localhost:8000/docsThe default admin account is seeded from SEED_ADMIN_USERNAME /
SEED_ADMIN_PASSWORD on first startup (set both in .env, or leave blank and
create users via /auth/register after seeding one manually).
The API container mounts the host's LXD socket. Two socket paths exist, depending on how LXD was installed:
| Install | Socket path |
|---|---|
| snap | /var/snap/lxd/common/lxd/unix.socket |
| native | /var/lib/lxd/unix.socket |
.env:
LXD_CONNECTION_MODE=local
LXD_SOCKET_PATH=/var/snap/lxd/common/lxd/unix.socketdocker-compose.yml mounts it read-only:
volumes:
- "${LXD_SOCKET_PATH}:/var/snap/lxd/common/lxd/unix.socket:ro"LXD's network API authenticates with client certificates (this is what
lxc remote add <name> <url> does — it exchanges certs and trusts them).
Generate a client cert, add it to the LXD server's trust store, then point the
API at the cert/key files.
Generate a client cert/key pair:
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout client.key -out client.crt -subj "/CN=lxd-api"Trust it on the LXD server (one of):
# From a trusted machine:
lxc config trust add client.crt
# Or via the LXD REST API on the server:
lxc remote add lxd-api https://lxd-host:8443 # then confirm fingerprint.env:
LXD_CONNECTION_MODE=remote
LXD_REMOTE_URL=https://lxd-host:8443
LXD_CLIENT_CERT_PATH=/run/secrets/lxd/client.crt
LXD_CLIENT_KEY_PATH=/run/secrets/lxd/client.key
LXD_TRUSTED_CA_PATH=/run/secrets/lxd/server-ca.crt # "" to skip verification (dev)Mount the cert directory in compose (uncomment the LXD_TLS_DIR volume):
volumes:
- "${LXD_TLS_DIR:-./tls}:/run/secrets/lxd:ro"All settings live in .env (see .env.example). Key ones:
| Variable | Default | Description |
|---|---|---|
APP_PORT |
8000 |
HTTP port |
CORS_ORIGINS |
* |
Comma-separated allowed origins |
LXD_CONNECTION_MODE |
local |
local or remote |
LXD_SOCKET_PATH |
/var/snap/lxd/common/lxd/unix.socket |
Local-mode socket |
LXD_REMOTE_URL |
https://lxd-host:8443 |
Remote-mode LXD URL |
LXD_CLIENT_CERT_PATH |
Client cert (remote mTLS) | |
LXD_CLIENT_KEY_PATH |
Client key (remote mTLS) | |
LXD_TRUSTED_CA_PATH |
Server CA (remote); "" = skip verify |
|
LXD_TIMEOUT |
30 |
Per-request LXD timeout (s) |
DATABASE_URL |
sqlite+aiosqlite:///./data/lxd_api.db |
User store (JWT subjects) |
JWT_SECRET |
(change me) | HS256 signing secret |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
Access token lifetime |
JWT_REFRESH_TOKEN_EXPIRE_DAYS |
7 |
Refresh token lifetime |
SEED_ADMIN_USERNAME |
First-run admin username | |
SEED_ADMIN_PASSWORD |
First-run admin password |
Roles are hierarchical — require_role("operator") also admits admin.
| Role | Level | Can do |
|---|---|---|
viewer |
1 | All GET endpoints (read-only) |
operator |
2 | Instance lifecycle, exec/console, snapshots/backups, attach/detach |
admin |
3 | Everything: CRUD on pools/networks/projects/images, user management |
Examples: POST /instances/{name}/start → operator+; DELETE /storage/pools/{name} → admin only.
# Login (rate-limited) -> access + refresh tokens
TOKEN=$(curl -s localhost:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"changeme123"}' | jq -r .access_token)
# Use it
curl localhost:8000/api/v1/instances -H "Authorization: Bearer $TOKEN"
# Refresh
curl localhost:8000/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}"WebSocket auth: browsers can't set headers on a WS upgrade, so pass the
JWT as a query param: ws://host/api/v1/instances/{name}/exec/ws?token=<jwt>.
See api.http for a complete, runnable collection (works in VS Code REST
Client / JetBrains). Highlights per resource:
# Instances
curl localhost:8000/api/v1/instances -H "Authorization: Bearer $TOKEN"
curl -X POST localhost:8000/api/v1/instances -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"web1","source":{"type":"image","alias":"ubuntu/22.04"}}'
curl -X PUT localhost:8000/api/v1/instances/web1/state -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"action":"start"}'
# Project-scoped request
curl "localhost:8000/api/v1/instances?project=staging" -H "Authorization: Bearer $TOKEN"
# Storage
curl localhost:8000/api/v1/storage/pools -H "Authorization: Bearer $TOKEN"
# Networks
curl localhost:8000/api/v1/networks -H "Authorization: Bearer $TOKEN"
# Projects
curl -X POST localhost:8000/api/v1/projects -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"staging"}'
# Images (async — returns an operation ref)
curl -X POST localhost:8000/api/v1/images -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"source":{"type":"image","alias":"ubuntu/22.04","server":"https://images.linuxcontainers.org","protocol":"simplestreams"}}'
# Operations (poll the async op from above)
curl localhost:8000/api/v1/operations/<id>/wait -H "Authorization: Bearer $TOKEN"
# System
curl localhost:8000/api/v1/system/info -H "Authorization: Bearer $TOKEN"
curl localhost:8000/api/v1/system/healthMany LXD actions (create instance, copy image, snapshot, migrate) are
long-running. LXD returns 202 Accepted + an operation URL immediately and
runs the work in the background. Blocking the HTTP request until completion
would tie up a worker and risk client timeouts.
So this API returns an operation reference right away:
{
"operation_id": "abc-123",
"operation_url": "/1.0/operations/abc-123",
"poll_url": "/api/v1/operations/abc-123",
"wait_url": "/api/v1/operations/abc-123/wait"
}The client then does one of:
- Poll —
GET /api/v1/operations/{id} - Long-poll —
GET /api/v1/operations/{id}/wait?timeout=30 - Subscribe — open
WS /api/v1/operations/wsfor real-time events
?expand=true(default) → LXDrecursion=1(full objects, not URLs).?filter=status eq Running→ passed through to LXD's OData filter.?limit=20&offset=40→ our own pagination applied on top of the LXD result.?instance-type=container(orvirtual-machine) on instance list.?project=staging→ scopes the request to a LXD project.
make install # pip install -r requirements.txt + requirements-dev.txt
make migrate # alembic upgrade head
make test # pytest with coverage
make lint # ruff check + black --check
make format # black + ruff --fix
make typecheck # mypy (non-blocking)Tests use an in-memory SQLite DB and a mocked LXD client — no real LXD daemon is required:
make testapp/
main.py # FastAPI app, middleware, lifespan, error handler
api/
deps.py # JWT bearer, RBAC require_role, project param
v1/
api.py # v1_router aggregator
routes/ # auth, instances, snapshots, backups, storage,
# networks, projects, images, operations, system
core/
config.py # pydantic-settings
security.py # bcrypt + JWT + Role hierarchy
limiter.py # slowapi instance
schemas/ # pydantic v2 request/response models
services/
lxd_client.py # raw httpx wrapper of the LXD REST API
lxd_operations.py # async-op ref builder + wait helper
exceptions.py # LXDError hierarchy -> HTTP status
db/ # SQLAlchemy async models, session, crud, seed
utils/ # JSON logging, pagination
alembic/ # migrations
tests/ # pytest (mocked LXD)
Dockerfile, docker-compose.yml, Makefile, api.http
Interactive docs are available at /docs (Swagger) and /redoc once running.
See api.http for a full request collection and lxd-api.postman_collection.json
for a Postman import.
MIT