Deploying on EC2
This guide walks through a single-host deployment of Tuwunel, Zooid, and the Zooid web client. EC2 is the running example; any comparable VPS — Hetzner, DigitalOcean, Linode, a Raspberry Pi on your desk — follows the same steps as long as it’s running Ubuntu 24.04.
Why this deployment shape?
Zooid is a container-spawning daemon — it launches one rootless agent container per workforce member, keeps it alive across turns, and tears it down when idle. That makes it different from a normal stateless web service that fits neatly into Fargate, Cloud Run, or App Runner. The daemon itself needs a host with a container runtime it can drive, plus somewhere to keep per-agent state across turns.
This guide picks the simplest shape that satisfies those constraints: one
Linux box, rootless podman as the agent runtime, Zooid as a host process.
A Kubernetes runtime — where Zooid creates Pods instead of local containers,
unlocking EKS / GKE / self-managed clusters — is on the roadmap but not
built yet. Docker works today as a drop-in for podman (runtime: docker in
zooid.yaml), if that’s more familiar; you’d lose the rootless security
story but the architecture is identical. Podman supports docker compose
out of the box, too, so a compose file is a reasonable next step if you
want one.
For now, treat this guide as the canonical “single-box, self-hosted” path. If you need horizontal scale or managed orchestration today, the bottleneck isn’t the docs — it’s the Kubernetes runtime we haven’t shipped yet.
What runs on the box
A single EC2 instance, one domain, four things running on it:
- Tuwunel — Matrix homeserver, rootless podman container, bound to
127.0.0.1:8448. - Zooid — agent daemon, host process. Connects to Tuwunel as an Application Service.
- Agent containers — one rootless podman container per agent, started on first use.
- Caddy — system service. Auto-provisions a Let’s Encrypt cert, serves the Zooid web client at
/, and reverse-proxies/_matrix/*to Tuwunel.
Public traffic only hits Caddy on 80/443. The homeserver itself isn’t reachable from the internet — agents and the web client talk to it on localhost.
Prerequisites
- Ubuntu 24.04 EC2 instance (t3.small or larger, 20GB root volume).
- A domain with an A record pointing to the box — e.g.
agents.example.com. HTTPS is required (browsers refuse plain-httpMatrix homeservers from the Zooid web client), so a real DNS name is non-optional. - Security group: open TCP 80 + 443 (80 is required for the Let’s Encrypt HTTP-01 challenge). Leave 8448 closed — Caddy proxies it.
- SSH access.
- API key for at least one LLM provider (e.g.,
ANTHROPIC_API_KEY,OPENCODE_API_KEY).
In the examples below, substitute SERVER_NAME for your domain
(e.g. agents.example.com).
1. Install dependencies
sudo apt-get updatesudo apt-get install -y podman uidmap
# Node.js for the zooid CLIcurl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -sudo apt-get install -y nodejs
# Caddy (official repo)sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curlcurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpgcurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | sudo tee /etc/apt/sources.list.d/caddy-stable.listsudo apt-get update && sudo apt-get install -y caddy
# Zooid CLIsudo npm install -g zooidzooid --version2. Configure rootless podman for the ubuntu user
Rootless podman runs containers under your unprivileged user, so an agent
container escape lands as ubuntu rather than root. It needs a UID/GID range
mapped to your user so it can unpack image layers with arbitrary ownership:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ubuntupodman system migrate
cat /etc/subuid # should show: ubuntu:100000:65536podman run --rm hello-worldEnable user lingering so containers and the daemon survive after SSH exits:
sudo loginctl enable-linger ubuntu3. Lay out the data dir
mkdir -p ~/zooid/workforcemkdir -p ~/zooid/data/matrix/config/registrationssudo mkdir -p /var/www/zoonsudo chown ubuntu:ubuntu /var/www/zoon~/zooid/workforce/ is the daemon’s working directory — where zooid.yaml
lives and where each agent’s per-agent workspace sits as a subdirectory
(agents/<name>/, bind-mounted into the container as /workspace).
~/zooid/data/matrix/ holds Tuwunel’s database, media, and AS config.
/var/www/zoon is where Caddy will serve the Zooid web client SPA from.
4. Generate Application Service tokens + registration file
The AS registration is the contract between Tuwunel and Zooid: matching tokens and a regex describing which Matrix user IDs Zooid is allowed to impersonate.
cat >> ~/zooid/.env <<EOFSERVER_NAME=agents.example.comMATRIX_AS_TOKEN=as-$(openssl rand -hex 24)MATRIX_HS_TOKEN=hs-$(openssl rand -hex 24)EOFchmod 600 ~/zooid/.envsource ~/zooid/.env
cat > ~/zooid/data/matrix/config/registrations/zooid.yaml <<EOFid: zooidurl: http://host.docker.internal:9000as_token: \${MATRIX_AS_TOKEN}hs_token: \${MATRIX_HS_TOKEN}sender_localpart: zooidrate_limited: falsenamespaces: users: - exclusive: false regex: '@.*:\${SERVER_NAME}' aliases: - exclusive: false regex: '#.*:\${SERVER_NAME}' rooms: []EOF5. Write tuwunel.toml
cat > ~/zooid/data/matrix/config/tuwunel.toml <<EOF[global]server_name = "\${SERVER_NAME}"database_path = "/var/lib/tuwunel/db"media_path = "/var/lib/tuwunel/media"appservice_dir = "/var/lib/tuwunel/registrations"address = ["0.0.0.0"]port = [8448]allow_registration = trueyes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = trueallow_local_presence = true
# Scope the user directory so a user only sees others they share a room with.show_all_local_users_in_user_directory = false# Auto-join new users to the space first, then its default room. Order matters:# #general is restricted-to-the-space, so the user must be a space member# before the restricted join can succeed.auto_join_rooms = ["#dev:\${SERVER_NAME}", "#general:\${SERVER_NAME}"]EOFallow_registration = true keeps registration open while you bring users
online. Once everyone’s signed in, flip it to false and restart Tuwunel.
6. Write zooid.yaml
Substitute SERVER_NAME in user_namespace for your domain before writing
the file — the <<'EOF' heredoc does not expand it:
cat > ~/zooid/workforce/zooid.yaml <<'EOF'runtime: podman
transports: matrix: homeserver: http://localhost:8448 port: 9000 # AS listener; MUST equal the registration url's port user_namespace: '@.*:SERVER_NAME' # MUST set explicitly — substitute SERVER_NAME
agents: zooid-assistant: acp: preset: claude container: env: ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} matrix: display_name: 'Zooid Assistant' rooms: ['#zooid'] trigger: mentionEOFAdd your provider key to .env:
echo "ANTHROPIC_API_KEY=sk-..." >> ~/zooid/.envThe rest is inferred: matrix.user_id → @zooid-assistant:SERVER_NAME,
sender_localpart → zooid, as_token/hs_token → from .env.
Per-agent config (opencode.json if using the opencode preset, or an
optional AGENTS.md persona) lives in ~/zooid/workforce/agents/zooid-assistant/
— that subdirectory is the agent’s working dir, bind-mounted into the
container at /workspace.
7. Build and deploy the Zooid web client
Clone the zooid-clients repo on your laptop,
build the SPA, and scp the dist to /var/www/zoon. The EC2 box doesn’t
need the repo or a Node toolchain — only the built dist/ is shipped.
# On your laptop:git clone https://github.com/zooid-ai/clients.git zooid-clientscd zooid-clientspnpm installpnpm -C packages/web build
# Then copy the built SPA to the box:scp -r packages/web/dist/* ubuntu@SERVER_NAME:/var/www/zoon/Drop a runtime config so the SPA finds the homeserver at the same origin:
ssh ubuntu@SERVER_NAME "echo '{\"homeserver_url\":\"https://SERVER_NAME\"}' > /var/www/zoon/config.json"8. Configure Caddy
A single Caddyfile serves the Zooid web client SPA at / and proxies the Matrix paths
to Tuwunel. Same origin, so no CORS dance:
sudo tee /etc/caddy/Caddyfile > /dev/null <<EOF${SERVER_NAME} { encode gzip
handle /_matrix/* { reverse_proxy localhost:8448 } handle /.well-known/matrix/* { reverse_proxy localhost:8448 }
handle { root * /var/www/zoon try_files {path} /index.html file_server }}EOF
sudo systemctl reload caddyCaddy auto-provisions the Let’s Encrypt cert on first request to your domain. Confirm:
curl -sI https://${SERVER_NAME}/ | head -1 # HTTP/2 2009. Systemd user units for Tuwunel + Zooid
Caddy runs as a system service (managed by its apt package). Tuwunel and Zooid run as user services so they share the same UID namespace as the rootless podman containers.
Tuwunel:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/zooid-tuwunel.service <<'EOF'[Unit]Description=Tuwunel Matrix homeserver (for zooid)After=network-online.target
[Service]Type=simpleExecStartPre=-/usr/bin/podman rm -f zooid-tuwunelExecStart=/usr/bin/podman run --rm --name zooid-tuwunel \ -p 127.0.0.1:8448:8448 \ --add-host=host.docker.internal:host-gateway \ -v %h/zooid/data/matrix/db:/var/lib/tuwunel/db \ -v %h/zooid/data/matrix/media:/var/lib/tuwunel/media \ -v %h/zooid/data/matrix/config/tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro \ -v %h/zooid/data/matrix/config/registrations:/var/lib/tuwunel/registrations:ro \ -e TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml \ ghcr.io/matrix-construct/tuwunel:latestExecStop=/usr/bin/podman stop zooid-tuwunelRestart=on-failureRestartSec=5
[Install]WantedBy=default.targetEOFNote the -p 127.0.0.1:8448:8448 — that’s what keeps the homeserver off
the public internet. Caddy proxies it.
Zooid:
cat > ~/.config/systemd/user/zooid.service <<'EOF'[Unit]Description=zooid agent daemonAfter=network-online.target zooid-tuwunel.serviceWants=zooid-tuwunel.service
[Service]Type=simpleWorkingDirectory=%h/zooid/workforceEnvironmentFile=%h/zooid/.envExecStart=/usr/bin/zooid startRestart=on-failureRestartSec=5
[Install]WantedBy=default.targetEOF10. Start everything
systemctl --user daemon-reloadsystemctl --user enable --now zooid-tuwunel zooidsystemctl --user status zooid-tuwunel zooidLogs:
journalctl --user -u zooid-tuwunel -fjournalctl --user -u zooid -fTuwunel takes 10–20s to become healthy on first boot (DB init). The Zooid unit retries until Tuwunel is up.
11. Smoke test — and make yourself admin
Open https://SERVER_NAME in a browser. You’ll land on the Zooid web client login
screen. Since there’s no signup flow in the UI, register your operator
account first:
curl -sX POST https://SERVER_NAME/_matrix/client/v3/register \ -H content-type:application/json \ -d '{"auth":{"type":"m.login.dummy"},"username":"ori","password":"<pw>"}'Sign in to the Zooid web client with those credentials.
At this point you’ll notice you have PL 0 everywhere. zooid dev would
have seeded you at PL 100 in the workforce space, #general, and each agent
room. zooid start deliberately doesn’t — the next section walks through
the manual bootstrap that gets you to the same state.
What zooid dev does for you that zooid start doesn’t
zooid dev is opinionated for a fresh laptop: it boots Tuwunel, registers
an admin user, and seeds the workforce space + every agent room so you can
sign in and immediately start using the chat. Production zooid start
does none of that — the operator is expected to bootstrap their identity
out of band.
What zooid dev does | Production equivalent |
|---|---|
Registers an admin user (@admin:SERVER_NAME, password admin) via /_matrix/client/v3/register on first run. | You registered yourself in step 11. |
Threads --admin-user admin into the daemon so the operator MXID is known. | zooid start never accepts --admin-user; the daemon runs without an operator identity. |
Seeds the operator at PL 100 in: the workforce space, every agent room created by bot-pool, and #general. Also adds the operator to the invite: list at space creation, so the (invite-only) space is actually joinable. | None of the above happen. The first daemon-created room/space has only the AS bot at PL 100, with users_default: 0 for everyone else. |
The reason for the asymmetry: the daemon writes power levels once, at room creation, and never reads or modifies them after that — promote/demote in the UI is canonical and survives restarts. In production, the first admin is bootstrapped via Tuwunel’s own mechanisms (below), keeping the daemon out of the business of duplicating Tuwunel’s admin model.
Manual bootstrap, the rest of step 11
Grant yourself homeserver-admin via the @conduit bot
Tuwunel (Conduit/conduwuit lineage) ships an admin bot at
@conduit:SERVER_NAME. The first user to register on a fresh server is
DM’d by that bot, and the resulting DM room is the admin room — every
message you send in it is parsed as an admin command. If you registered
first in step 11, you should see that DM in the Zooid web client’s room list.
Confirm you’re admin:
@conduit:SERVER_NAME: list-appservices(Lists the registered AS — works only for homeserver admins.)
Invite yourself to the workforce space and join
The space is invite-only. The only member at this point is the AS bot,
@zooid:SERVER_NAME, at PL 100. Have @conduit force-join you to the space:
@conduit:SERVER_NAME: force-join-room @ori:SERVER_NAME #dev:SERVER_NAMEGrant yourself PL 100 in the space, #general, and each agent room
Power levels are per-room state in Matrix — being PL 100 in the space does
not make you PL 100 in #general, the agent rooms, or anywhere else.
The admin bot runs the grants:
@conduit:SERVER_NAME: set-room-power-level @ori:SERVER_NAME 100 #dev:SERVER_NAME@conduit:SERVER_NAME: set-room-power-level @ori:SERVER_NAME 100 #general:SERVER_NAME@conduit:SERVER_NAME: set-room-power-level @ori:SERVER_NAME 100 #zooid:SERVER_NAME# …repeat for each agent room declared in zooid.yamlAfter this you’ll match the state zooid dev creates on a laptop: member
of the space, member of #general and each agent room, PL 100 in all of
them, able to invite new users and use the space switcher’s ”+ Add channel”
plus the roles UI.
Verify the agent works
Open #zooid:SERVER_NAME in the Zooid web client. @-mention @zooid-assistant:SERVER_NAME
and the agent container spins up on first use:
podman ps# CONTAINER ID IMAGE STATUS# a3f1b2c4d5e6 ghcr.io/zooid-ai/agent-claude-code:... Up 2 minutesThe container stays running across turns — Zooid pipes each new prompt to
the existing ACP session over stdio. It only goes away if the daemon is
stopped or the agent is removed from zooid.yaml.
Close registration
Once everyone you intend to onboard has registered, tighten Tuwunel:
[global]allow_registration = falseSubsequent users would need to be created by an admin
(@conduit: create-user).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
newuidmap: executable file not found | uidmap not installed | sudo apt install uidmap |
insufficient UIDs in user namespace | subuid not configured | Step 2 |
| Container stops when SSH exits | linger not enabled | sudo loginctl enable-linger ubuntu |
Caddy: tls: handshake failure from a browser | Let’s Encrypt couldn’t reach the box on 80 during issuance | Open TCP 80 in the security group; sudo systemctl reload caddy to retry |
Caddy logs: dial tcp 127.0.0.1:8448: connect: connection refused | Tuwunel container not up yet, or -p not bound to 127.0.0.1 | systemctl --user status zooid-tuwunel; verify the -p 127.0.0.1:8448:8448 line |
Tuwunel logs: appservice config not found | registration file path mismatch | Check the mount in step 9 matches step 4’s output path |
Zooid logs: 401 from /transactions | token mismatch between .env and registrations/zooid.yaml | Re-source .env, regenerate registration if needed |
Tuwunel logs: Could not send request to appservice ... 127.0.0.1:9000 Connection refused | registration url is localhost (= the tuwunel container itself), or --add-host missing | Set url: http://host.docker.internal:9000 (step 4) and --add-host=host.docker.internal:host-gateway (step 9) |
Tuwunel logs: Could not send request to appservice ... Connection refused (after host.docker.internal is correct) | port mismatch — the registration YAML’s url port doesn’t match transports.matrix.port in zooid.yaml (or the inferred default of 9000) | Make both ports match; ss -tln | grep 9000 confirms the AS is listening |
Human register fails: M_EXCLUSIVE: Username is reserved by an appservice | users namespace is exclusive: true over @.* | Set exclusive: false (step 4), restart tuwunel |
Agent join fails: M_EXCLUSIVE: User is not in namespace | user_namespace inferred as @…:localhost, mismatching server_name | Pin user_namespace: '@.*:SERVER_NAME' (step 6) |
Joining #dev:SERVER_NAME returns M_FORBIDDEN: cannot join a room that is not public | space is invite-only and you’re not invited | Do the @conduit force-join-room step from §11 |
Joined #general but still at PL 0 | only the operator MXID supplied to zooid dev gets seeded at PL 100; production needs the manual grant | set-room-power-level step from §11 |
Agent runs the turn but posts nothing: turn finished with empty buffer | model returned ~no message text, or overlapping turns desync the send buffer | Use a model that reliably emits a final message; send one mention at a time while debugging |
port 80 already in use (Caddy) | another web server bound to it | ss -tlnp | grep ':80 '; remove the conflict |
Updating
# zooid CLIsudo npm install -g zooid@latestsystemctl --user restart zooid
# Tuwunel (image pull happens on next start)podman pull ghcr.io/matrix-construct/tuwunel:latestsystemctl --user restart zooid-tuwunel
# Agent images (latest tag): pulled automatically next turnpodman pull ghcr.io/zooid-ai/agent-claude-code:latest
# Zooid web client: pull the latest in your local clone, rebuild, re-scp dist/git -C zooid-clients pullpnpm -C zooid-clients/packages/web buildscp -r zooid-clients/packages/web/dist/* ubuntu@SERVER_NAME:/var/www/zoon/
# Caddysudo apt-get update && sudo apt-get install -y caddysudo systemctl reload caddy