Skip to content

For AI Agents ​

This page is written for AI agents that install, operate, or build on a Terrarium host. The same content is published as a single Markdown file, which is easier for agents to fetch and parse:

agents.md. Point your agent at this URL, or add it to the agent's context.

This file is for AI agents that install, operate, or build on a Terrarium host. Human docs: https://terrarium.terion.name/ · Source: https://github.com/terion-labs/terrarium Terrarium is beta software (current release line 0.0.0-betaN). If this file and terrariumctl --help disagree, trust --help on the host.

What Terrarium is ​

Terrarium turns a fresh Ubuntu Server 24.04 or 26.04 VPS into a hardened host for isolated LXD system containers (LXC) on ZFS. It installs and configures:

  • LXD with its web UI. It runs unprivileged system containers; Terrarium targets containers, not VMs.
  • OpenZFS, with the terrarium storage pool holding all containers.
  • sanoid / syncoid for automatic ZFS snapshots and optional replication to another ZFS host.
  • S3 export of ZFS snapshot streams for disaster recovery (optional).
  • Traefik on the host as the only ingress: Let's Encrypt TLS, with routes generated from container labels.
  • oauth2-proxy as an OIDC gate for the management UIs and for any published route you mark @auth.
  • Identity provider: a local ZITADEL (default) or local Logto running in the managed terrarium-idp instance, or any external OIDC issuer.
  • Cockpit for host administration, with ZFS and S3 browser plugins.
  • UFW firewall, key-only SSH, and devsec.hardening host hardening.
  • Clustering (optional): LXD clustering over a WireGuard mesh, with an OVN overlay network shared by all nodes.

The CLI is terrariumctl. trm is a symlink to the same binary. Both are in /usr/local/bin.

Core model (read this first) ​

  1. Containers are private by default. Every container sits on the terrarium-ovn private network. A service listening on 0.0.0.0:5432 inside a container cannot be reached from the internet.
  2. Publishing is explicit. A service goes public only when the LXD config key user.proxy is set on its container. A host timer turns labels into Traefik routes every 60 seconds. terrariumctl proxy sync applies them immediately.
  3. Backends must listen on 0.0.0.0 inside the container. Traefik cannot reach a service bound only to 127.0.0.1.
  4. The time machine is automatic. sanoid snapshots every container: 4 × 15-minute, 24 hourly, 14 daily, and 3 monthly snapshots are kept.
  5. Containers are unprivileged, with security.idmap.isolated=true. Root inside a container is not root on the host. Docker can run inside containers that use the default, terrarium, or dev profile.
  6. Config lives in LXD's database: dqlite, project terrarium-system, key user.terrarium.config_b64. /etc/terrarium/config.yaml is only a transient or on-demand export. Never edit it by hand. Change settings with terrariumctl set ....
  7. Work as the non-root terrarium user inside containers. trm exec defaults to that user. Only the dev profile gives it passwordless sudo.

Where each command runs ​

Commands in this file run in one of three places. Get this right before running anything.

ContextExamplesWho
Host shell (SSH to the VPS as root)terrariumctl ..., trm ..., lxc ...root on the Terrarium host
Inside a containerapt-get, docker compose, your appreach it with trm exec NAME -- CMD from the host
Your workstationssh, provider CLIs (hcloud, doctl), DNSwherever the agent runs

If you run without a TTY, always pass a command: trm exec NAME -- bash -lc '...'. Plain trm exec NAME opens an interactive login shell.

Requirements and sizing ​

  • Ubuntu Server 24.04 or 26.04 LTS, x86_64 or arm64. Root access and key-based SSH.
  • Strongly recommended: a second, empty block volume for ZFS (--storage-mode disk). Without one, use --storage-mode file, which puts a file-backed pool on the root disk (default size 64G).
  • Minimum: 2 vCPU, 4 GB RAM, 30–40 GB boot disk, 80–120 GB ZFS disk.
  • Recommended: 4 vCPU, 8–16 GB RAM, 150–300 GB ZFS disk.
  • ZFS sizing rule: 2–3× the live data you expect, to leave room for snapshot history.
  • Provider fit: DigitalOcean, Hetzner Cloud, and Vultr support attachable volumes and private networks (use disk mode). Hostinger has no attachable volumes (use file mode).
  • On 26.04, the first install builds the Cockpit ZFS Python binding from pinned source. This needs GitHub and PyPI access and takes longer.

Install ​

Run as root on the fresh host.

Interactive (for a human at the keyboard) ​

bash
curl -fsSL https://github.com/terion-labs/terrarium/releases/latest/download/install.sh | bash

install.sh is a thin bootstrap. It downloads the release bundle, which contains the compiled terrariumctl and the Ansible assets, into /opt/terrarium and runs terrariumctl install. It accepts --ref TAG (a tag-like ref downloads that release, a branch-like ref such as main builds from source) and --update. It forwards every other flag to terrariumctl install. To pin a release:

bash
curl -fsSL https://github.com/terion-labs/terrarium/releases/download/0.0.40/install.sh | bash

Non-interactive (for agents and automation) ​

bash
curl -fsSL https://github.com/terion-labs/terrarium/releases/latest/download/install.sh | bash -s -- \
  --non-interactive --yes \
  --email admin@example.com \
  --acme-email certs@example.com \
  --domain example.com \
  --idp local --idp-provider zitadel \
  --generate-root-pwd \
  --storage-mode disk --storage-source auto

Required in non-interactive mode: --email, --idp, and --storage-mode. You also need --storage-source for disk and partition modes. If root has no local password, you also need --generate-root-pwd or --root-pwd-file, because Cockpit uses a PAM login.

Pass secrets as files, not argv: --root-pwd-file, --oidc-secret-file, --lxd-oidc-secret-file, --s3-secret-key-file. Create them with install -m 600 /dev/null /root/secret && printf '%s\n' 'value' > /root/secret.

The installer checks integrations before it saves anything. It probes external OIDC (issuer, callback flow, client credentials) and runs a real write/delete against S3. In non-interactive mode, a failed check stops the install with an error.

Install flags ​

AreaFlags
Identity--email, --acme-email (defaults to --email)
Domains--domain ROOT (derives manage., proxy., lxd., and auth. hosts from it); overrides: --manage-domain, --proxy-domain, --lxd-domain, --auth-domain. Without --domain, hosts default to <svc>.<dashed-public-ip>.traefik.me
IDP--idp local|oidc, --idp-provider zitadel|logto, --admin-group (default terrarium-admins; required for oidc), --oidc ISSUER, --oidc-client, --oidc-secret[-file], --lxd-oidc-client, --lxd-oidc-secret[-file], --oidc-groups-claim, --oidc-scopes, --lxd-oidc-groups-claim, --lxd-oidc-scopes, --zitadel-admin-email, --logto-admin-email, --logto-admin-username (default terrarium_admin)
Cockpit root--generate-root-pwd (writes /etc/terrarium/secrets/cockpit_root_password) or --root-pwd-file PATH
Storage--storage-mode disk|partition|file, --storage-source PATH|auto, --storage-size 64G (file mode)
S3--enable-s3, --s3-endpoint (default https://s3.amazonaws.com), --s3-bucket, --s3-region (default us-east-1), --s3-prefix (default terrarium), --s3-access-key, --s3-secret-key[-file]
Syncoid--enable-syncoid, --syncoid-target root@host, --syncoid-target-dataset backup/terrarium, --syncoid-ssh-key /root/.ssh/id_ed25519
Control--non-interactive, --yes, --ref

Storage modes:

  • disk: Terrarium takes over an entire empty non-root disk. Recommended.
  • partition: an unused partition or free space on a non-root disk.
  • file: a file-backed pool on the root filesystem.

Terrarium never shrinks the root filesystem. --storage-source auto picks the largest valid non-root target.

Choosing the IDP ​

  • --idp local --idp-provider zitadel is the default and uses fewer resources.
  • --idp local --idp-provider logto is heavier but has a more polished admin and user UI. It runs Logto and Postgres inside terrarium-idp.
  • --idp oidc points Terrarium at an external issuer (Google Workspace, Auth0, ZITADEL Cloud, Logto Cloud, ...). Add --idp-provider logto for Logto or Logto Cloud issuers.

After install ​

The installer prints the endpoints and where to find the credentials:

  • Cockpit https://manage.<domain>, Traefik dashboard https://proxy.<domain>, LXD UI/API https://lxd.<domain>, local IDP https://auth.<domain>.
  • Local IDP bootstrap admin password:
    • ZITADEL: lxc exec terrarium-idp -- cat /etc/terrarium/secrets/zitadel_admin_password
    • Logto: lxc exec terrarium-idp -- cat /etc/terrarium/secrets/logto_admin_password
  • Generated Cockpit root password: /etc/terrarium/secrets/cockpit_root_password.
  • With custom domains, create A records for manage., proxy., lxd., and auth. (local IDP only) pointing at the host's public IP. With the default traefik.me hosts, no DNS setup is needed.

Health check: terrariumctl status shows services, endpoints, IDP mode, admin group, and the oauth2-proxy state.

Access model: SSH is key-only. Cockpit requires OIDC login, then a PAM login as root. The Traefik dashboard requires OIDC login. LXD uses native OIDC. Management access is limited to members of the admin group, which is separate from per-app route groups.

Containers ​

Launching ​

trm launch wraps lxc launch. A plain lxc launch also works, and both get the Terrarium profiles.

bash
trm launch ubuntu:24.04 web-01                                   # default profile
trm launch ubuntu:24.04 devbox --profile dev                     # dev sandbox with sudo
trm launch ubuntu:24.04 web-01 --disk 40G --memory 4G --cpu 2    # resource limits
trm launch ubuntu:24.04 app --proxy https://app.example.com:8080 # publish at launch

Managed profiles ​

Each profile is a complete, standalone profile: it includes the network and root disk. Pass one of them. Repeating --profile layers profiles, as LXD does.

ProfileNesting / Dockerterrarium user sudoUse for
defaultyesnonenormal app containers
terrariumyesnoneexplicit alias of default
strictnononeworkloads that don't need Docker or nesting
devyespasswordlessagent sandboxes, dev boxes, anything that installs packages
kvmadds /dev/kvmn/alayered on top of another profile (--profile dev --profile kvm); created only when the host exposes /dev/kvm

All cloud-init images get a locked terrarium user (home /home/terrarium) with a Python venv at ~/.venvs/default on its PATH.

Running commands ​

bash
trm exec NAME                               # login shell as terrarium
trm exec NAME -- bash -lc 'docker ps'       # run a command (always use "--")
trm exec NAME --root -- systemctl status x  # as root, for recovery or admin work
trm exec NAME --user ubuntu                 # another container user

Other useful commands: lxc list, lxc list NAME -c n4 (private IPv4), lxc stop|start|restart|delete NAME, lxc config show NAME.

Templating containers with trm launch ​

Provisioning flags generate cloud-init that runs inside the new container on first boot:

Flag (all repeatable except --cloud-init)Effect
--playbook PATH|GITinstalls Ansible and runs ansible-playbook -i localhost, -c local
--requirements PATH|GITruns ansible-galaxy install with roles and/or collections first
--role NAMEinstalls and runs a Galaxy role, e.g. geerlingguy.docker
--docker-compose PATH|GITinstalls Docker and runs docker compose -f FILE up -d
--var KEY=value / --vars file.envvariables exported to provisioning shell, Ansible --extra-vars, and Compose --env-file. Inline --var overrides dotenv values
--cloud-init PATHraw user-data that replaces the generated template. Cannot be combined with the flags above
--proxy ROUTEvalidated and written to user.proxy

Local files are embedded in the cloud-init. Git assets are cloned inside the container, using this format: git+https://github.com/org/repo.git//path/in/repo.yml?ref=v1.0.0. Pin ref to a tag or commit.

Execution order: packages, git clones, Galaxy requirements, Galaxy roles, playbooks, Docker Compose.

bash
trm launch ubuntu:24.04 portal \
  --profile dev --disk 80G --memory 6G --cpu 4 \
  --vars ./portal.env --var APP_ENV=production \
  --requirements ./ansible/requirements.yml \
  --playbook ./ansible/base.yml \
  --docker-compose ./compose/portal.yml \
  --proxy https://portal.example.com:8080@auth:admins

Compose ports: publish inside the container only. Point --proxy at the container port.

Debugging (inside the container): sudo cloud-init status --long, sudo tail -n 200 /var/log/cloud-init-output.log, sudo docker compose ps, sudo docker compose logs --tail=100.

Publishing services: the user.proxy label ​

Set the label from the host, then sync:

bash
lxc config set NAME user.proxy "https://app.example.com:8080"
terrariumctl proxy sync

To edit it in the LXD UI: instance → Configuration → Edit YAML → config: { user.proxy: ... }.

HTTP(S) grammar ​

text
https://<public-host>[:container-port][/path][@auth[:group[,group...]][~callback-host]]
http://<public-host>[:container-port][/path][@auth...]      # plain HTTP only when intended
  • https:// gets a Let's Encrypt certificate and redirects HTTP to HTTPS. Public listeners are always 80/443.
  • :port is the port inside the container. The default is 80.
  • /path is matched as a prefix. Query strings and fragments are not allowed.
  • @auth requires an OIDC login. @auth:g1,g2 also requires membership in any listed group. Group names may contain letters, digits, ., _, and -.
  • ~callback-host is required only for wildcard hosts with @auth. It gives the concrete oauth2-proxy callback host.
  • You can put several routes in one label, separated by commas or newlines. user.proxy is a single key, so set all of a container's routes in one value. Setting it again replaces the previous routes.
bash
lxc config set app user.proxy "https://app.example.com:8080,https://api.example.com:3000/api"
lxc config set tool user.proxy "https://tool.example.com:3000@auth:admins,devops"
lxc config set hermes user.proxy "https://hermes-api.example.com:8642,https://hermes-dash.example.com:9119@auth"

TCP / UDP ​

text
tcp://<public-port>:<container-port>
udp://<public-port>:<container-port>
bash
lxc config set postgres user.proxy "tcp://15432:5432"

Raw transport has no hostnames, paths, TLS, or @auth. Terrarium creates a Traefik entrypoint and opens the managed UFW port.

Wildcards (DNS-01) ​

bash
terrariumctl set dns provider cloudflare CF_DNS_API_TOKEN:token
terrariumctl set dns provider route53 AWS_ACCESS_KEY_ID:k AWS_SECRET_ACCESS_KEY:s AWS_REGION:us-east-1
lxc config set app user.proxy "https://*.example.com:8080"
lxc config set ui user.proxy "https://*.example.com:3000@auth:admins~auth.example.com"
terrariumctl set dns provider        # no args: disable DNS-01, return to HTTP-01

Provider codes and variable names are lego's: https://go-acme.github.io/lego/dns/index.html. Traefik supports one DNS provider at a time.

Route rules and gotchas ​

  • The container must be in the default LXD project and have an IPv4 address on the Terrarium network.
  • Duplicate host/path claims and duplicate TCP/UDP public ports are rejected during sync. proxy sync exits non-zero on errors, so read its output.
  • @auth routes must be under the Terrarium root domain. For example, with root example.com you can protect app.example.com but not other.com. With the default traefik.me setup, use app.<dashed-ip>.traefik.me. It resolves with no DNS work.
  • Non-wildcard custom hosts need a DNS A record pointing at the host (or at every node in a cluster).
  • API endpoints that clients call with API keys should not use @auth. OAuth redirects break non-browser clients. Protect them with the app's own auth instead.
  • Keep the app's own password or token enabled even behind @auth, as defense in depth.

Authentication and the IDP ​

Terrarium uses one OIDC client for Cockpit's oauth2-proxy, LXD, and @auth routes. LXD can use a separate client (--lxd-oidc-client plus --lxd-oidc-secret-file).

Local IDP (ZITADEL or Logto): Terrarium provisions the clients, creates the admin role (named after the admin group, default terrarium-admins), grants it to the bootstrap admin, and emits the claim. ZITADEL emits a flat groups claim. Logto uses roles with scopes openid profile email roles. With local ZITADEL, proxy sync also registers each @auth route's callback URL. terrariumctl idp sync reconciles local IDP apps, roles, and claims. Create users and assign roles in the IDP's admin UI at https://auth.<domain>.

External OIDC: Terrarium never changes your provider. You must configure:

  • Callbacks: https://<manage-domain>/oauth2/callback, https://<proxy-domain>/oauth2/callback, https://<lxd-domain>/oidc/callback.
  • A callback for every @auth route: https://<host>/oauth2/callback for root routes, or https://<host>/oauth2/<path>/callback for path routes (e.g. /admin → /oauth2/admin/callback). Add these whenever you create a protected route.
  • The groups/roles claim as a JSON string array containing the admin group. Generic OIDC and ZITADEL default to groups. Logto defaults to roles. A role assignment on the provider side is not enough unless the token or userinfo actually carries the claim.
  • ZITADEL Cloud also needs "Return user roles during authentication" and "User roles inside ID Token" enabled, plus a Complement Token Action that copies project roles into groups. The script is at https://terrarium.terion.name/getting-started/domains-and-auth.

Sharing data between containers ​

Local shared volume (fast, on ZFS, stays on this VPS):

bash
lxc storage volume create terrarium agent-memory
lxc storage volume attach terrarium agent-memory openclaw agent-memory /srv/shared-memory
lxc storage volume attach terrarium agent-memory hermes   agent-memory /srv/shared-memory
lxc exec openclaw -- chown -R terrarium:terrarium /srv/shared-memory

For example, mount one volume at /home/terrarium/.codex in several containers to share a CLI login.

External SMB/CIFS storage (e.g. a Hetzner Storage Box; survives VPS loss, can also be mounted from a laptop, slower):

bash
terrariumctl mount add cifs /srv/shared/storage-box //u12345.your-storagebox.de/backup u12345 --password-file /root/sb-pass
terrariumctl mount add cifs /srv/shared/storage-box //u12345.your-storagebox.de/backup u12345 --container app --container-path /mnt/shared
terrariumctl mount attach /srv/shared/storage-box app /mnt/shared
terrariumctl mount list
terrariumctl mount remove /srv/shared/storage-box

Subaccounts use //u12345-sub1.your-storagebox.de/u12345-sub1 u12345-sub1. Credentials are stored under /etc/terrarium/mounts/, and the mount is written as a managed /etc/fstab block. SMB encryption is on by default (--seal true). Prefer mount add --container or mount attach over a raw lxc config device add ... disk source=..., because they remap ownership for unprivileged containers. Don't put databases or active git repositories on SMB.

Backups and restore ​

Automatic layers:

  • sanoid ZFS snapshots every 15 minutes, with the retention described above.
  • Optional S3 export, hourly timer: compressed incremental ZFS streams.
  • Optional syncoid replication, hourly, to another ZFS host over SSH.
bash
terrariumctl backup list                                   # local snapshots + S3 manifests
terrariumctl backup export                                 # push to S3 now
terrariumctl backup restore --instance app                 # in place, latest snapshot (OVERWRITES)
terrariumctl backup restore --instance app --at autosnap_2026-04-19_10:00:00_hourly
terrariumctl backup restore --instance app --as-new app-restored
terrariumctl backup restore --source s3 --instance app     # from S3
  • In-place restore stops the container, rolls it back, and destroys newer state. It asks for confirmation.
  • --as-new restores to a separate dataset and then hands off to the upstream interactive lxd recover. The restored copy has its user.proxy removed, so it can't take over live traffic.
  • Both restore paths need a TTY and a human confirmation. An agent without a TTY should prepare the exact command and ask the user to run it.
  • Before a risky change, a quick non-interactive checkpoint with plain LXD works too: lxc snapshot app pre-change, then roll back with lxc restore app pre-change.

Local IDP data lives in terrarium-idp and is covered by the same machinery: terrariumctl idp status|logs [--lines N]|backup|restore [--source local|s3] [--at ...] [--as-new NAME].

Golden images ​

A golden image turns a configured container into a reusable template:

bash
trm image create web-01 golden-web                      # temporary snapshot, sanitized, published
trm image create web-01 golden-web --snapshot known-good
trm image create web-01 golden-web --live               # no temporary snapshot
trm image create web-01 golden-web --snapshot known-good --reuse   # replace existing alias
trm image launch golden-web web-02 --profile dev --disk 40G --proxy https://web-02.example.com:8080
trm image list
trm image delete golden-web

Images are stripped of user.proxy and LXD proxy devices. image launch accepts --profile, --disk, --memory, --cpu, and --proxy.

Reconfiguration and updates ​

Each set command saves to the dqlite store, re-runs Ansible (skipping OS hardening), and re-syncs the proxy and IDP:

bash
terrariumctl set domains example.com [--manage-domain ...] [--proxy-domain ...] [--lxd-domain ...] [--auth-domain ...]
terrariumctl set emails --email ops@example.com --acme-email certs@example.com
terrariumctl set idp local --provider zitadel|logto
terrariumctl set idp oidc [--provider logto] --oidc https://issuer --oidc-client terrarium \
  --oidc-secret-file /root/oidc-secret --admin-group terrarium-admins
terrariumctl set s3 --enable --s3-endpoint https://... --s3-bucket b --s3-region r \
  --s3-access-key AK --s3-secret-key-file /root/s3-secret      # also: --disable
terrariumctl set syncoid --enable --syncoid-target root@backup --syncoid-target-dataset backup/terrarium \
  --syncoid-ssh-key /root/.ssh/id_ed25519                      # also: --disable
terrariumctl set dns provider [code] [KEY:VALUE...]
terrariumctl reconfigure                  # full re-apply from saved config
terrariumctl update [--ref TAG] [--skip-reconfigure]
terrariumctl config export | import       # YAML copy at /etc/terrarium/config.yaml (root-only)
terrariumctl completion bash|zsh|fish [install]   # or: completion all install
  • set idp oidc and set s3 run the same live checks as the installer before saving.
  • Switching between local providers reuses terrarium-idp and disables the other provider's services. Take an IDP backup first.
  • Running the installer again on an existing host offers to update. install.sh --update behaves the same as terrariumctl update.

Clustering (optional) ​

This is native LXD clustering over a WireGuard mesh (default 10.255.54.0/24, UDP 51820), with the terrarium-ovn overlay spanning all nodes. Use at least 3 nodes for quorum. Put them in the same region, on a provider private network if one is available.

bash
# node1 (Terrarium installed):
terrariumctl cluster init [--wireguard-endpoint 10.42.0.11:51820]
terrariumctl cluster invite node2 [10.42.0.12]     # prints the join command
# node2 (fresh host, Terrarium installed):
terrariumctl cluster join --token '<token>' --wireguard '<bundle>' --yes
  • Joining wipes the joining node's local LXD state. Join only fresh nodes.
  • Provider firewalls should allow only 51820/udp between member IPs. Never expose 8443, 6641, 6642, or 6081; they travel inside WireGuard, and OVN also uses mTLS.
  • Traefik runs on every node, and any node can serve any published route. Use round-robin A records or a load balancer.
  • A container still lives on one node's disk. Clustering gives you one management plane, not automatic HA.

Operations:

bash
terrariumctl cluster status
terrariumctl cluster move app1 node2
terrariumctl cluster evacuate node2 --yes
terrariumctl cluster restore node2 --yes
terrariumctl cluster remove node2 --move [--target node1] --yes
terrariumctl cluster remove node2 --force          # dead node; metadata only
terrariumctl cluster ovn configure                 # then `terrariumctl reconfigure` on other members
terrariumctl cluster token node3                   # raw LXD token only

Recipes ​

Each recipe is condensed. Full guides are at https://terrarium.terion.name/guides/.

AI agent sandbox (OpenClaw, Hermes, Codex/Claude CLIs, ...)

bash
trm launch ubuntu:24.04 agent --profile dev --disk 40G --memory 4G
trm exec agent -- bash -lc 'sudo apt-get update && sudo apt-get install -y git curl'
# install the agent as the terrarium user; keep its UI private (SSH tunnel / Tailscale inside the container),
# or bind it to 0.0.0.0 with its own password and publish behind SSO:
lxc config set agent user.proxy "https://agent.example.com:18789@auth"
terrariumctl proxy sync
  • OpenClaw: gateway on port 18789. For public access set "bind": "lan" and password auth in ~/.openclaw/openclaw.json.
  • Hermes: API on 8642. Publish it without @auth and protect it with API_SERVER_KEY. Dashboard on 9119, run with --insecure, and only publish it with @auth.
  • To keep an agent's memory outside the VPS, attach an external mount at the memory path (~/.openclaw/workspace, ~/.hermes/memories).

Browser IDE (code-server): in a dev container, install with curl -fsSL https://code-server.dev/install.sh | sh. Write ~/.config/code-server/config.yaml with bind-addr: 0.0.0.0:8080, auth: password, a random password:, and cert: false (Traefik terminates TLS). Then run sudo systemctl enable --now code-server@terrarium and publish with https://code.example.com:8080@auth.

Docker Compose stack: trm launch ubuntu:24.04 stack --profile dev --docker-compose ./compose.yml --proxy https://app.example.com:8080. Publish only the frontend port and leave the database without ports:.

Dokploy / Coolify: run the control plane in one dev container behind @auth (Dokploy on :3000; Coolify on :8000 for setup, then :80). Deployment servers are separate containers that the control plane reaches over SSH on their private IP (lxc list NAME -c n4). Coolify can use terrarium with sudo; Dokploy needs root with key-only SSH. Terrarium owns ingress, so add each app domain to the app server's user.proxy pointing at :80. Traffic flows: Terrarium Traefik → app-server container → platform proxy → app.

Troubleshooting checklist ​

  1. Route not responding: is the service bound to 0.0.0.0 inside the container (trm exec NAME -- ss -ltnp)? Did terrariumctl proxy sync exit 0? Does DNS resolve to the host? Check the Traefik dashboard at proxy.<domain>.
  2. @auth loop or 403: with an external IDP, is the route callback registered? Does the token carry the configured claim (groups or roles) with the required group? Is the host under the root domain?
  3. Launch looks stuck: cloud-init is still running. Check cloud-init status --long and /var/log/cloud-init-output.log.
  4. Docker fails in a container: the container is on strict. Use default or dev.
  5. sudo denied for terrarium: expected outside the dev profile. Use trm exec NAME --root or relaunch with --profile dev.
  6. Host state: terrariumctl status, terrariumctl idp status, journalctl -u traefik, systemctl list-timers 'terrarium*'.

Reference: paths and ports ​

PathContents
/opt/terrarium/installed bundle (terrariumctl binary and Ansible assets)
/usr/local/bin/terrariumctl, /usr/local/bin/trmCLI
/etc/terrarium/secrets/generated secrets (root-only)
/etc/terrarium/mounts/managed SMB credentials
/etc/terrarium/config.yamloptional export only; do not edit
/var/lib/terrarium/state, S3 manifests, oauth2-proxy config

Public ports: 22/tcp (key-only SSH), 80/tcp, 443/tcp, plus any tcp:// or udp:// route ports you publish. In a cluster, add 51820/udp between members only.

Rules for agents operating a Terrarium host ​

  • Keep services private unless the user asks to publish them. Default to @auth for anything with an admin UI.
  • Never put secrets on the command line when a *-file flag exists. Never print secret files into logs or chat unless the user asks.
  • Change config with terrariumctl set ..., not by editing /etc/terrarium/config.yaml or the dqlite key.
  • Confirm with the user before any of these: in-place backup restore, cluster join (wipes LXD on that node), cluster remove --force, mount remove, lxc delete, set idp (can lock users out of management UIs), set domains (changes every management URL).
  • Take an lxc snapshot NAME pre-<change> before risky work inside a container. Automatic snapshots only run every 15 minutes.
  • Work inside containers as terrarium. Use --root only when needed.
  • Each lxc config set NAME user.proxy replaces the previous value. Read the current value with lxc config get NAME user.proxy before you change it.

Human documentation index ​

Native Linux VMs ​

Use trm launch ubuntu:24.04 NAME --vm --wait or Ubuntu 26.04. --target MEMBER chooses placement; the member must advertise VM support for the image architecture. --timeout SECONDS bounds agent/cloud-init readiness (default 300). VM defaults are 2 CPUs, 2 GiB RAM, and 20 GiB root storage. Select vm-dev for passwordless sudo; container profiles, including kvm, are incompatible with --vm.

Both launch entry points accept the same provisioning, variable, resource, and route options. Local golden VM images infer type without --vm. Use trm exec NAME for the terrarium user and --root explicitly. If the agent cannot connect, inspect lxc console NAME and cloud-init; never replay an ambiguous user command automatically.

VM service listeners must bind to a guest network interface, not only loopback. Existing user.proxy HTTP/HTTPS/TCP/UDP/auth labels work unchanged. Host-local terrarium-ingress-* helper containers provide the OVN path; leave these infrastructure instances out of workload operations.

A VM backup is a metadata filesystem plus a block volume from one atomic snapshot. Use trm backup restore for reconstruction, --as-new for a separate copy, and --source s3 for off-host restore. Versioned S3 chains carry checksums and source identity; retain every referenced parent and component. In-place restore retains previous disks and asks before replacement. LXD recovery remains interactive. New VM copies prepare guest identities in network quarantine before normal use.

trm mount attach HOST_PATH VM /guest/path supports directories and CIFS through LXD, using the terrarium guest UID/GID. Ownership changes cannot disrupt existing share consumers. Prefer --instance/--instance-path; old container flag names remain aliases. Detach host-bound devices before trm cluster move VM MEMBER. Movement preflights capability, architecture and storage, then uses bounded graceful shutdown; a stop failure does not authorize forced power off.

See https://terrarium.terion.name/getting-started/virtual-machines for the full operator workflow.

Built with VitePress