A worked example of running a Tusile community server on a cloud VM, using AWS EC2. Nothing here is AWS-specific except the CLI commands — the sizing, ports, and setup steps apply to any VPS. The canonical install flow is Install guide; this page covers what a cloud host has to decide on top of it.

Two things that will waste your afternoon if you skip them.

1. x86 only. The community-server image is published for linux/amd64 and compose pins that platform. Graviton instances (t4g, m7g, anything ending in g) cannot run it. Pick a t3 or other Intel/AMD type.

2. A plain public IP is not stable. It survives a reboot but not a stop/start — the address changes and the domain you pointed at it goes dead. Allocate an Elastic IP if the server is anything other than a throwaway test. It costs nothing while attached to a running instance.

Sizing

The stack is five containers: Postgres, LiveKit, Caddy, the community server, and a small cert-sync sidecar. Memory is the binding constraint, not CPU.

Give the root volume at least 12 GB. The five images plus Amazon Linux plus a 2 GB swapfile do not fit comfortably in the 8 GB default.

Security group

This is the same port list the setup script prints at the end. Lock SSH to your own address; everything else has to be open for the server and voice to work from the internet.

SG=$(aws ec2 create-security-group --group-name tusile-community \
      --description "Tusile community server" --vpc-id <your-default-vpc> \
      --query GroupId --output text)

aws ec2 authorize-security-group-ingress --group-id "$SG" --ip-permissions \
  'IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=YOUR.IP.HERE/32}]' \
  'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=7880,ToPort=7881,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=5349,ToPort=5349,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=udp,FromPort=3478,ToPort=3478,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=udp,FromPort=50000,ToPort=50060,IpRanges=[{CidrIp=0.0.0.0/0}]'

Launch with Docker preinstalled

Amazon Linux 2023 has docker in its repositories but not the Compose plugin, so that one is fetched directly. Save this as userdata.sh:

#!/bin/bash
set -eux
exec > /var/log/tusile-bootstrap.log 2>&1

# Swap. Skip only on 2 GB or more.
dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

dnf install -y docker
systemctl enable --now docker
usermod -aG docker ec2-user

# Compose plugin is not in the AL2023 repos.
mkdir -p /usr/libexec/docker/cli-plugins
curl -sSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 \
     -o /usr/libexec/docker/cli-plugins/docker-compose
chmod +x /usr/libexec/docker/cli-plugins/docker-compose

touch /var/lib/tusile-bootstrap-done

Then launch. Resolve the current Amazon Linux 2023 x86_64 AMI rather than pasting an id that will be stale in a month:

AMI=$(aws ec2 describe-images --owners amazon \
  --filters "Name=name,Values=al2023-ami-2023.*-kernel-6.1-x86_64" "Name=state,Values=available" \
  --query 'reverse(sort_by(Images,&CreationDate))[0].ImageId' --output text)

aws ec2 run-instances \
  --image-id "$AMI" --instance-type t3.small \
  --key-name YOUR-KEYPAIR --security-group-ids "$SG" --subnet-id <a-public-subnet> \
  --block-device-mappings 'DeviceName=/dev/xvda,Ebs={VolumeSize=12,VolumeType=gp3}' \
  --user-data file://userdata.sh \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=tusile-community}]'

Bootstrap takes about 80 seconds. Wait for /var/lib/tusile-bootstrap-done before continuing, rather than guessing.

Point DNS at it first

Do this before running setup. Caddy asks Let's Encrypt for a certificate during the install, and that fails until the name resolves to the instance. Create an A record for your domain pointing at the instance's public IP, then confirm from the instance itself:

getent ahostsv4 chat.example.com    # must print the instance's public IP
curl -s https://api.ipify.org       # ...the same address

A free subdomain from Duck DNS, No-IP, or FreeDNS is fine. Offgrid servers can skip this and use the bare IP, but then Let's Encrypt cannot issue and the server falls back to its own certificate authority — desktop and mobile ask you to confirm the fingerprint once, and the web client cannot connect at all.

Install, unattended

Copy the bundle up and run setup with flags, so nothing waits for a keypress:

scp -i key.pem community-server/* ec2-user@YOUR.IP:/home/ec2-user/community-server/
ssh -i key.pem ec2-user@YOUR.IP

cd community-server && chmod +x setup.sh

# Cloud - accounts via tusile.com. Create the server in the app first for the token.
./setup.sh --cloud --token YOUR_TOKEN --hostname chat.example.com --yes

# Offgrid - standalone; local usernames and passwords, no tusile.com contact.
./setup.sh --offgrid --hostname chat.example.com --yes

Check the exit code rather than reading the output: 0 means the stack is up and serving, 1 means bad arguments or a hostname a cloud install cannot work on, and 2 means the stack did not come up — setup prints the failing container's logs and is safe to re-run.

An Offgrid install prints a one-time owner claim token at the end. It stays on the data volume until it is used, so you can read it again at any time:

docker compose exec -T community-server cat /app/data/owner_claim_token

Voice arrives a few minutes after everything else

Setup returns as soon as the server is serving. On a first run with a public domain, LiveKit keeps restarting for a while afterwards — it will not start without its TURN certificate, Caddy has to obtain that from Let's Encrypt first, and the livekit-cert-sync sidecar copies it across on a five-minute cycle. Expect voice about six minutes behind text. It self-heals; watch it if you want:

docker compose logs -f livekit

Verify

Switching Cloud to Offgrid on the same machine

Bring the old stack down before re-running setup. The compose project name comes from .env, and setup rewrites that file — so if you re-run setup first, compose no longer knows the old project, leaves those containers running, and the new stack cannot bind ports 80, 443, or 7880.

cd community-server
docker compose down                 # volumes are kept
./setup.sh --offgrid --hostname chat.example.com --yes

The two stacks get different project names, so the Offgrid server starts with an empty database rather than inheriting the Cloud one. That is deliberate: Offgrid accounts are local, and there is nothing to map Tusile accounts onto. The old volumes stay on disk until you remove them with docker volume rm.

Tear it down

Terminating the instance deletes its root volume only if DeleteOnTermination was set, which is the default for the root device. The security group and key pair survive termination and have to go separately.

aws ec2 terminate-instances --instance-ids i-xxxxxxxx
aws ec2 wait instance-terminated --instance-ids i-xxxxxxxx
aws ec2 delete-security-group --group-id "$SG"   # only after the ENI is gone
aws ec2 delete-key-pair --key-name YOUR-KEYPAIR

aws ec2 describe-volumes --filters Name=status,Values=available   # should be empty
aws ec2 describe-addresses                                       # release any unattached EIP

An unattached Elastic IP is billed by the hour. Check for one even if you think you never allocated it.

Last updated: 2026-07-31.