Skip to content

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:

  1. Tuwunel — Matrix homeserver, rootless podman container, bound to 127.0.0.1:8448.
  2. Zooid — agent daemon, host process. Connects to Tuwunel as an Application Service.
  3. Agent containers — one rootless podman container per agent, started on first use.
  4. 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-http Matrix 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

Terminal window
sudo apt-get update
sudo apt-get install -y podman uidmap
# Node.js for the zooid CLI
curl -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 curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update && sudo apt-get install -y caddy
# Zooid CLI
sudo npm install -g zooid
zooid --version

2. 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:

Terminal window
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ubuntu
podman system migrate
cat /etc/subuid # should show: ubuntu:100000:65536
podman run --rm hello-world

Enable user lingering so containers and the daemon survive after SSH exits:

Terminal window
sudo loginctl enable-linger ubuntu

3. Lay out the data dir

Terminal window
mkdir -p ~/zooid/workforce
mkdir -p ~/zooid/data/matrix/config/registrations
sudo mkdir -p /var/www/zoon
sudo 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.

Terminal window
cat >> ~/zooid/.env <<EOF
SERVER_NAME=agents.example.com
MATRIX_AS_TOKEN=as-$(openssl rand -hex 24)
MATRIX_HS_TOKEN=hs-$(openssl rand -hex 24)
EOF
chmod 600 ~/zooid/.env
source ~/zooid/.env
cat > ~/zooid/data/matrix/config/registrations/zooid.yaml <<EOF
id: zooid
url: http://host.docker.internal:9000
as_token: \${MATRIX_AS_TOKEN}
hs_token: \${MATRIX_HS_TOKEN}
sender_localpart: zooid
rate_limited: false
namespaces:
users:
- exclusive: false
regex: '@.*:\${SERVER_NAME}'
aliases:
- exclusive: false
regex: '#.*:\${SERVER_NAME}'
rooms: []
EOF

5. Write tuwunel.toml

Terminal window
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 = true
yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true
allow_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}"]
EOF

allow_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:

Terminal window
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: mention
EOF

Add your provider key to .env:

Terminal window
echo "ANTHROPIC_API_KEY=sk-..." >> ~/zooid/.env

The rest is inferred: matrix.user_id@zooid-assistant:SERVER_NAME, sender_localpartzooid, 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.

Terminal window
# On your laptop:
git clone https://github.com/zooid-ai/clients.git zooid-clients
cd zooid-clients
pnpm install
pnpm -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:

Terminal window
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:

Terminal window
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 caddy

Caddy auto-provisions the Let’s Encrypt cert on first request to your domain. Confirm:

Terminal window
curl -sI https://${SERVER_NAME}/ | head -1 # HTTP/2 200

9. 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:

Terminal window
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=simple
ExecStartPre=-/usr/bin/podman rm -f zooid-tuwunel
ExecStart=/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:latest
ExecStop=/usr/bin/podman stop zooid-tuwunel
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF

Note the -p 127.0.0.1:8448:8448 — that’s what keeps the homeserver off the public internet. Caddy proxies it.

Zooid:

Terminal window
cat > ~/.config/systemd/user/zooid.service <<'EOF'
[Unit]
Description=zooid agent daemon
After=network-online.target zooid-tuwunel.service
Wants=zooid-tuwunel.service
[Service]
Type=simple
WorkingDirectory=%h/zooid/workforce
EnvironmentFile=%h/zooid/.env
ExecStart=/usr/bin/zooid start
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF

10. Start everything

Terminal window
systemctl --user daemon-reload
systemctl --user enable --now zooid-tuwunel zooid
systemctl --user status zooid-tuwunel zooid

Logs:

Terminal window
journalctl --user -u zooid-tuwunel -f
journalctl --user -u zooid -f

Tuwunel 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:

Terminal window
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 doesProduction 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_NAME

Grant 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.yaml

After 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:

Terminal window
podman ps
# CONTAINER ID IMAGE STATUS
# a3f1b2c4d5e6 ghcr.io/zooid-ai/agent-claude-code:... Up 2 minutes

The 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 = false

Subsequent users would need to be created by an admin (@conduit: create-user).


Troubleshooting

SymptomCauseFix
newuidmap: executable file not founduidmap not installedsudo apt install uidmap
insufficient UIDs in user namespacesubuid not configuredStep 2
Container stops when SSH exitslinger not enabledsudo loginctl enable-linger ubuntu
Caddy: tls: handshake failure from a browserLet’s Encrypt couldn’t reach the box on 80 during issuanceOpen TCP 80 in the security group; sudo systemctl reload caddy to retry
Caddy logs: dial tcp 127.0.0.1:8448: connect: connection refusedTuwunel container not up yet, or -p not bound to 127.0.0.1systemctl --user status zooid-tuwunel; verify the -p 127.0.0.1:8448:8448 line
Tuwunel logs: appservice config not foundregistration file path mismatchCheck the mount in step 9 matches step 4’s output path
Zooid logs: 401 from /transactionstoken mismatch between .env and registrations/zooid.yamlRe-source .env, regenerate registration if needed
Tuwunel logs: Could not send request to appservice ... 127.0.0.1:9000 Connection refusedregistration url is localhost (= the tuwunel container itself), or --add-host missingSet 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 appserviceusers namespace is exclusive: true over @.*Set exclusive: false (step 4), restart tuwunel
Agent join fails: M_EXCLUSIVE: User is not in namespaceuser_namespace inferred as @…:localhost, mismatching server_namePin user_namespace: '@.*:SERVER_NAME' (step 6)
Joining #dev:SERVER_NAME returns M_FORBIDDEN: cannot join a room that is not publicspace is invite-only and you’re not invitedDo the @conduit force-join-room step from §11
Joined #general but still at PL 0only the operator MXID supplied to zooid dev gets seeded at PL 100; production needs the manual grantset-room-power-level step from §11
Agent runs the turn but posts nothing: turn finished with empty buffermodel returned ~no message text, or overlapping turns desync the send bufferUse 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 itss -tlnp | grep ':80 '; remove the conflict

Updating

Terminal window
# zooid CLI
sudo npm install -g zooid@latest
systemctl --user restart zooid
# Tuwunel (image pull happens on next start)
podman pull ghcr.io/matrix-construct/tuwunel:latest
systemctl --user restart zooid-tuwunel
# Agent images (latest tag): pulled automatically next turn
podman 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 pull
pnpm -C zooid-clients/packages/web build
scp -r zooid-clients/packages/web/dist/* ubuntu@SERVER_NAME:/var/www/zoon/
# Caddy
sudo apt-get update && sudo apt-get install -y caddy
sudo systemctl reload caddy