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 andterrariumctl --helpdisagree, trust--helpon 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
terrariumstorage 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-idpinstance, or any external OIDC issuer. - Cockpit for host administration, with ZFS and S3 browser plugins.
- UFW firewall, key-only SSH, and
devsec.hardeninghost 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)
- Containers are private by default. Every container sits on the
terrarium-ovnprivate network. A service listening on0.0.0.0:5432inside a container cannot be reached from the internet. - Publishing is explicit. A service goes public only when the LXD config key
user.proxyis set on its container. A host timer turns labels into Traefik routes every 60 seconds.terrariumctl proxy syncapplies them immediately. - Backends must listen on
0.0.0.0inside the container. Traefik cannot reach a service bound only to127.0.0.1. - The time machine is automatic. sanoid snapshots every container: 4 × 15-minute, 24 hourly, 14 daily, and 3 monthly snapshots are kept.
- 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, ordevprofile. - Config lives in LXD's database: dqlite, project
terrarium-system, keyuser.terrarium.config_b64./etc/terrarium/config.yamlis only a transient or on-demand export. Never edit it by hand. Change settings withterrariumctl set .... - Work as the non-root
terrariumuser inside containers.trm execdefaults to that user. Only thedevprofile gives it passwordless sudo.
Where each command runs
Commands in this file run in one of three places. Get this right before running anything.
| Context | Examples | Who |
|---|---|---|
| Host shell (SSH to the VPS as root) | terrariumctl ..., trm ..., lxc ... | root on the Terrarium host |
| Inside a container | apt-get, docker compose, your app | reach it with trm exec NAME -- CMD from the host |
| Your workstation | ssh, provider CLIs (hcloud, doctl), DNS | wherever 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 size64G). - 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
diskmode). Hostinger has no attachable volumes (usefilemode). - 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)
curl -fsSL https://github.com/terion-labs/terrarium/releases/latest/download/install.sh | bashinstall.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:
curl -fsSL https://github.com/terion-labs/terrarium/releases/download/0.0.40/install.sh | bashNon-interactive (for agents and automation)
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 autoRequired 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
| Area | Flags |
|---|---|
| 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 zitadelis the default and uses fewer resources.--idp local --idp-provider logtois heavier but has a more polished admin and user UI. It runs Logto and Postgres insideterrarium-idp.--idp oidcpoints Terrarium at an external issuer (Google Workspace, Auth0, ZITADEL Cloud, Logto Cloud, ...). Add--idp-provider logtofor Logto or Logto Cloud issuers.
After install
The installer prints the endpoints and where to find the credentials:
- Cockpit
https://manage.<domain>, Traefik dashboardhttps://proxy.<domain>, LXD UI/APIhttps://lxd.<domain>, local IDPhttps://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
- ZITADEL:
- Generated Cockpit root password:
/etc/terrarium/secrets/cockpit_root_password. - With custom domains, create A records for
manage.,proxy.,lxd., andauth.(local IDP only) pointing at the host's public IP. With the defaulttraefik.mehosts, 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.
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 launchManaged 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.
| Profile | Nesting / Docker | terrarium user sudo | Use for |
|---|---|---|---|
default | yes | none | normal app containers |
terrarium | yes | none | explicit alias of default |
strict | no | none | workloads that don't need Docker or nesting |
dev | yes | passwordless | agent sandboxes, dev boxes, anything that installs packages |
kvm | adds /dev/kvm | n/a | layered 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
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 userOther 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|GIT | installs Ansible and runs ansible-playbook -i localhost, -c local |
--requirements PATH|GIT | runs ansible-galaxy install with roles and/or collections first |
--role NAME | installs and runs a Galaxy role, e.g. geerlingguy.docker |
--docker-compose PATH|GIT | installs Docker and runs docker compose -f FILE up -d |
--var KEY=value / --vars file.env | variables exported to provisioning shell, Ansible --extra-vars, and Compose --env-file. Inline --var overrides dotenv values |
--cloud-init PATH | raw user-data that replaces the generated template. Cannot be combined with the flags above |
--proxy ROUTE | validated 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.
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:adminsCompose 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:
lxc config set NAME user.proxy "https://app.example.com:8080"
terrariumctl proxy syncTo edit it in the LXD UI: instance → Configuration → Edit YAML → config: { user.proxy: ... }.
HTTP(S) grammar
https://<public-host>[:container-port][/path][@auth[:group[,group...]][~callback-host]]
http://<public-host>[:container-port][/path][@auth...] # plain HTTP only when intendedhttps://gets a Let's Encrypt certificate and redirects HTTP to HTTPS. Public listeners are always 80/443.:portis the port inside the container. The default is80./pathis matched as a prefix. Query strings and fragments are not allowed.@authrequires an OIDC login.@auth:g1,g2also requires membership in any listed group. Group names may contain letters, digits,.,_, and-.~callback-hostis 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.proxyis a single key, so set all of a container's routes in one value. Setting it again replaces the previous routes.
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
tcp://<public-port>:<container-port>
udp://<public-port>:<container-port>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)
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-01Provider 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 syncexits non-zero on errors, so read its output. @authroutes must be under the Terrarium root domain. For example, with rootexample.comyou can protectapp.example.combut notother.com. With the defaulttraefik.mesetup, useapp.<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
@authroute:https://<host>/oauth2/callbackfor root routes, orhttps://<host>/oauth2/<path>/callbackfor 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 toroles. 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):
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-memoryFor 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):
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-boxSubaccounts 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.
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-newrestores to a separate dataset and then hands off to the upstream interactivelxd recover. The restored copy has itsuser.proxyremoved, 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 withlxc 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:
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-webImages 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:
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 installset idp oidcandset s3run the same live checks as the installer before saving.- Switching between local providers reuses
terrarium-idpand disables the other provider's services. Take an IDP backup first. - Running the installer again on an existing host offers to update.
install.sh --updatebehaves the same asterrariumctl 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.
# 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/udpbetween member IPs. Never expose8443,6641,6642, or6081; 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:
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 onlyRecipes
Each recipe is condensed. Full guides are at https://terrarium.terion.name/guides/.
AI agent sandbox (OpenClaw, Hermes, Codex/Claude CLIs, ...)
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@authand protect it withAPI_SERVER_KEY. Dashboard on9119, 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
- Route not responding: is the service bound to
0.0.0.0inside the container (trm exec NAME -- ss -ltnp)? Didterrariumctl proxy syncexit 0? Does DNS resolve to the host? Check the Traefik dashboard atproxy.<domain>. @authloop or 403: with an external IDP, is the route callback registered? Does the token carry the configured claim (groupsorroles) with the required group? Is the host under the root domain?- Launch looks stuck: cloud-init is still running. Check
cloud-init status --longand/var/log/cloud-init-output.log. - Docker fails in a container: the container is on
strict. Usedefaultordev. sudodenied forterrarium: expected outside thedevprofile. Usetrm exec NAME --rootor relaunch with--profile dev.- Host state:
terrariumctl status,terrariumctl idp status,journalctl -u traefik,systemctl list-timers 'terrarium*'.
Reference: paths and ports
| Path | Contents |
|---|---|
/opt/terrarium/ | installed bundle (terrariumctl binary and Ansible assets) |
/usr/local/bin/terrariumctl, /usr/local/bin/trm | CLI |
/etc/terrarium/secrets/ | generated secrets (root-only) |
/etc/terrarium/mounts/ | managed SMB credentials |
/etc/terrarium/config.yaml | optional 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
@authfor anything with an admin UI. - Never put secrets on the command line when a
*-fileflag exists. Never print secret files into logs or chat unless the user asks. - Change config with
terrariumctl set ..., not by editing/etc/terrarium/config.yamlor 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--rootonly when needed. - Each
lxc config set NAME user.proxyreplaces the previous value. Read the current value withlxc config get NAME user.proxybefore you change it.
Human documentation index
- Installation: https://terrarium.terion.name/getting-started/installation
- First instance: https://terrarium.terion.name/getting-started/creating-first-instance
- Templating (
trm launch): https://terrarium.terion.name/getting-started/templating - Storage and sizing: https://terrarium.terion.name/getting-started/storage
- Domains, routes, auth: https://terrarium.terion.name/getting-started/domains-and-auth
- Management GUIs: https://terrarium.terion.name/getting-started/management-guis
- Shared data: https://terrarium.terion.name/getting-started/shared-data-between-containers
- External storage: https://terrarium.terion.name/getting-started/external-shared-storage
- Reconfiguration: https://terrarium.terion.name/operations/reconfiguration
- Backups and restore: https://terrarium.terion.name/operations/backups-and-restore
- Golden images: https://terrarium.terion.name/operations/golden-images
- Clustering: https://terrarium.terion.name/operations/clustering
- CLI reference: https://terrarium.terion.name/reference/terrariumctl
- Services and endpoints: https://terrarium.terion.name/reference/services-and-endpoints
- Security model: https://terrarium.terion.name/security
- Architecture: https://terrarium.terion.name/architecture
- Guides (OpenClaw, Hermes, code-server, Compose, Dokploy, Coolify, OIDC): https://terrarium.terion.name/guides/
- Providers (DigitalOcean, Hetzner, Vultr, Hostinger): https://terrarium.terion.name/providers/
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.