# Can I self-host Twist?

**YES, IF** — it's called Zulip. ONGOING OPS setup · ~5 hours to running · 4 GB RAM minimum · $80/mo you stop paying ($960/yr on the Unlimited plan, 10 seats assumed).

Zulip authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/twist/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Zulip Server 12.2 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point here. It becomes `SETTING_EXTERNAL_HOST`, every invitation link
is built from it, and changing it later is a database edit.

Zulip needs 4096 MB of RAM available and 20 GB free on /srv. Upstream's floor is 2 GB for the
server alone; this runs five containers. Both architectures publish.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

One more thing, because step 3 stops dead without it. Zulip mails invitations, password resets
and notifications, and with no relay it raises no error: it loads a backend that accepts each
message and drops it. Have the user hold transactional relay credentials ready.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/zulip /srv/zulip/backups
sudo install -d -m 700 /srv/zulip/data /srv/zulip/postgres
ls -la /srv/zulip
```

Assert: `backups` owned by the login user, `data` and `postgres` at mode `700` owned by root.
Leave both alone: Zulip runs as root and makes `uploads` and `zulip-secrets.conf` under `data`,
and PostgreSQL chowns its own cluster.

## 3. Secrets

Five: the PostgreSQL, memcached, RabbitMQ and Redis passwords the containers authenticate to
each other with, plus the Django key that seals every session. Do not print them, repeat them
in your summary, or log them. Hex, because each is rewritten into a config file.

```bash
umask 077
cat > /srv/zulip/.env <<EOF
DOMAIN=<DOMAIN>
ZULIP_ADMIN_EMAIL=CHANGE_ME
ZULIP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
ZULIP_MEMCACHED_PASSWORD=$(openssl rand -hex 32)
ZULIP_RABBITMQ_PASSWORD=$(openssl rand -hex 32)
ZULIP_REDIS_PASSWORD=$(openssl rand -hex 32)
ZULIP_SECRET_KEY=$(openssl rand -hex 32)
MEMCACHED_SASL_DB=/home/memcache/memcached-sasl-db
ZULIP_EMAIL_HOST=CHANGE_ME
ZULIP_EMAIL_USER=CHANGE_ME
ZULIP_EMAIL_PASSWORD=CHANGE_ME
ZULIP_EMAIL_PORT=587
ZULIP_EMAIL_USE_TLS=True
ZULIP_EMAIL_USE_SSL=False
EOF
chmod 600 /srv/zulip/.env
umask 022
ls -l /srv/zulip/.env
```

Assert: mode `-rw-------`. Replace `<DOMAIN>` on the first line before writing. Compose reads
this file for interpolation and never hands it to a container.

STOP: tell the user to run `nano /srv/zulip/.env`, put their address in `ZULIP_ADMIN_EMAIL`,
replace the three mail `CHANGE_ME` lines with their relay's host, username and password, and
set `ZULIP_EMAIL_PORT=465` with `USE_TLS=False` and `USE_SSL=True` if the relay uses implicit
TLS. Do not continue until they confirm. Never ask them to paste a value.

```bash
grep -c CHANGE_ME /srv/zulip/.env
```

Assert: that prints `0`. It counts lines, never values.

## 4. compose.yml

```bash
cat > /srv/zulip/compose.yml <<'EOF'
# Zulip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   variables ... https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   entrypoint .. https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# Five services, which is what Zulip is. Upstream's compose.yaml runs the
# same five and publishes 25, 80 and 443; this publishes one loopback port
# and never 25. Secrets ride SECRETS_* variables, which entrypoint.sh copies
# into zulip-secrets.conf. The dependency images are pinned where upstream
# floats memcached:alpine, rabbitmq:4.2 and redis:alpine, each to what those
# resolved to when digests were read on 2026-08-14, all amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  database:
    image: zulip/zulip-postgresql:14@sha256:e71ba8616fa42cdc1b248f51263d9290c29681cb8c1992eb9b498af0bb656b29
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: ${ZULIP_POSTGRES_PASSWORD}
    volumes:
      - /srv/zulip/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      retries: 12

  memcached:
    image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    restart: unless-stopped
    # SASL: Zulip authenticates as zulip@localhost, as upstream sets up.
    command:
      - "sh"
      - "-euc"
      - |
        echo 'mech_list: plain' > /home/memcache/memcached.conf
        echo "zulip@$$HOSTNAME:$$MEMCACHED_PASSWORD" > "$$MEMCACHED_SASL_PWDB"
        echo "zulip@localhost:$$MEMCACHED_PASSWORD" >> "$$MEMCACHED_SASL_PWDB"
        exec memcached -S
    environment:
      SASL_CONF_PATH: /home/memcache/memcached.conf
      MEMCACHED_SASL_PWDB: ${MEMCACHED_SASL_DB}
      MEMCACHED_PASSWORD: ${ZULIP_MEMCACHED_PASSWORD}

  rabbitmq:
    image: rabbitmq:4.2.9@sha256:0104af7ef0d2bfff20b1e84a7177320d9b990531624d6b63f9dcf82d6de3b61b
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: ${ZULIP_RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq:/var/lib/rabbitmq

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - 'exec /usr/local/bin/docker-entrypoint.sh redis-server --requirepass "$$REDIS_PASSWORD"'
    environment:
      REDIS_PASSWORD: ${ZULIP_REDIS_PASSWORD}
    volumes:
      - redis:/data

  zulip:
    image: ghcr.io/zulip/zulip-server:12.2-0@sha256:765f0ab3caa49041989132ee1879d98dbab1df7695c27e713eac1f114d167755
    container_name: zulip
    restart: unless-stopped
    environment:
      # CERTIFICATES absent means plain HTTP on 80, behind a proxy.
      TRUST_GATEWAY_IP: "True"
      SETTING_EXTERNAL_HOST: ${DOMAIN}
      SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
      SETTING_REMOTE_POSTGRES_HOST: database
      SETTING_MEMCACHED_LOCATION: memcached:11211
      SETTING_RABBITMQ_HOST: rabbitmq
      SETTING_REDIS_HOST: redis
      SETTING_EMAIL_HOST: ${ZULIP_EMAIL_HOST}
      SETTING_EMAIL_HOST_USER: ${ZULIP_EMAIL_USER}
      SETTING_EMAIL_PORT: ${ZULIP_EMAIL_PORT}
      SETTING_EMAIL_USE_TLS: ${ZULIP_EMAIL_USE_TLS}
      SETTING_EMAIL_USE_SSL: ${ZULIP_EMAIL_USE_SSL}
      ZULIP_AUTH_BACKENDS: EmailAuthBackend
      # Upstream's small-deploy override; the default costs a gigabyte more.
      CONFIG_application_server__queue_workers_multiprocess: "False"
      SECRETS_postgres_password: ${ZULIP_POSTGRES_PASSWORD}
      SECRETS_memcached_password: ${ZULIP_MEMCACHED_PASSWORD}
      SECRETS_rabbitmq_password: ${ZULIP_RABBITMQ_PASSWORD}
      SECRETS_redis_password: ${ZULIP_REDIS_PASSWORD}
      SECRETS_secret_key: ${ZULIP_SECRET_KEY}
      SECRETS_email_password: ${ZULIP_EMAIL_PASSWORD}
    volumes:
      - /srv/zulip/data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8192.
      - "127.0.0.1:8192:80"
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started

volumes:
  rabbitmq:
  redis:
EOF
cd /srv/zulip && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No secret is in it: every value arrives as `${...}` from the
mode-600 `.env`, so an unset-variable complaint points at step 3.

## 5. Caddy and TLS

Append the block below, `<DOMAIN>` replaced. Copy the file first: a syntax error takes down
every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-zulip
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zulip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zulip.readthedocs.io/projects/docker/en/latest/how-to/compose-ssl.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also SETTING_EXTERNAL_HOST in
# compose.yml, and every invitation link is built from it.

<DOMAIN> {
	# Zulip sends its own CSP and X-Frame-Options; setting either here
	# would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8192 is the loopback port compose publishes here: not a container
	# port, not open in the firewall. Caddy adds X-Forwarded-For and
	# X-Forwarded-Proto itself, which is what TRUST_GATEWAY_IP tells Zulip
	# to believe, and it carries the long poll at /json/events unaided.
	reverse_proxy 127.0.0.1:8192
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-zulip, reload, and
report the objection.

## 6. Firewall

Two ports open, both Caddy's. Idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge, 443/tcp is the way in, 443/udp is HTTP/3. 8192 is bound to
127.0.0.1; 5432, 11211, 5672 and 6379 have no host port; 25 stays shut because this install
runs no incoming email gateway. Assert: `Status: active`, 80 and 443 present, nothing else.

## 7. Start and verify

Upstream boots this in two moves: a one-shot container that validates and migrates, then the
server. The first fails loudly, the second slowly.

```bash
cd /srv/zulip
docker compose pull
docker compose run --rm zulip app:init
```

Assert: the last line is `=== End Initial Configuration Phase ===`. Anything else, stop and read
the output: a variable renamed in 12.x means an 11.x example got edited in, a database timeout
means step 2.

```bash
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/new/ | grep -c 'Organization creation link required'
```

Assert all four and print what you got. The loop ends on `200`; that endpoint queries
PostgreSQL, round-trips memcached, pings Redis and opens a RabbitMQ channel, so one `200` is all
five answering. The body is `{"result":"success","msg":""}`. The root prints `404`, headed
`No organization found`. The grep prints `1`: `OPEN_REALM_CREATION` is off by default, so the
public creation page already refuses strangers, and there is no claim race here to lose.

The way in is a single-use link made on the server, written where only the user reads it:

```bash
umask 077
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py generate_realm_creation_link | grep -o 'https://[^[:space:]]*/new/[A-Za-z0-9]*' > /srv/zulip/realm-link.txt
umask 022
ls -l /srv/zulip/realm-link.txt
wc -l < /srv/zulip/realm-link.txt
```

Assert: mode `-rw-------`, and `1`. The link lasts seven days and is spent on first use.

STOP: tell the user to read it with `cat /srv/zulip/realm-link.txt`, open it, and complete the
page headed `Create a new Zulip organization` with their organization name, the address from
`ZULIP_ADMIN_EMAIL`, and a password saved to their password manager first.
Do not continue until they confirm they are signed in. The link skips email confirmation, so
it works even if the relay is wrong.

```bash
rm -f /srv/zulip/realm-link.txt
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/new/ | grep -c 'Organization creation link required'
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py shell -c "from zerver.models import Realm; print([(r.string_id or '(root)', r.invite_required) for r in Realm.objects.all()])"
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py send_test_email "$(grep '^ZULIP_ADMIN_EMAIL=' /srv/zulip/.env | cut -d= -f2)"
```

Assert all four. The root prints `200`. The grep prints `1` again: the link was spent, the door
is still shut. The third prints a list where every `invite_required` reads `True`, Zulip saying
nobody joins uninvited. The last exits 0; a relay that refuses the message raises here rather
than failing quietly later. A running container is not success.

STOP: tell the user to check that mailbox and confirm the two test messages arrived.
Do not continue until they confirm. If nothing lands within two minutes, read step 10 first.

## 8. First backup and restore

`app:backup` writes a fresh dump into the data directory; the tar carries that dump, the
uploads, the secrets file, `.env`, compose.yml and the live Caddy block.

```bash
cd /srv/zulip
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
sudo tar -czf /srv/zulip/backups/zulip-$(date +%F).tar.gz -C /srv/zulip compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/zulip/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing stops: `pg_dump` snapshots
a running database consistently, and `postgres` is left out because a live cluster copied file
by file is not a backup. Nor is one on the same disk:

```bash
mkdir -p ~/backups/zulip
scp vps:/srv/zulip/backups/*.tar.gz ~/backups/zulip/
```

To restore cold: `docker compose down`, recreate `/srv/zulip/postgres` as in step 2, untar the
archive into /srv/zulip with `sudo` so `.env` is back first, `docker compose up -d database`, wait for
healthy, `docker compose run --rm zulip app:restore <filename>` naming a `backup-*.sql` file
from /srv/zulip/data/backups, then `docker compose up -d`. The dump is every message,
`data/uploads` every shared file, `data/zulip-secrets.conf` the keys that let the restored
server recognise its own sessions.

## 9. Updating later

New versions: https://github.com/zulip/docker-zulip/releases. Upstream publishes no floating
tags, and the Docker Hub `zulip/docker-zulip` images stopping at 11.6-0 are the end-of-life
packaging, so the tags that matter are on ghcr.io. Back up, then edit the image line to the new
tag and digest.

```bash
cd /srv/zulip
docker compose pull
docker compose up -d
docker compose logs --tail 40 zulip
```

Zulip migrates on the way up. Watch that log until it settles, re-run step 7's `/health` check,
and move one major at a time: upstream refuses floating tags because a major bump carries a
migration worth scheduling.

## 10. What will probably go wrong

The first `docker compose up -d` looks broken for several minutes and is not. I watched
`/health` answer `502`, then `500`, then nothing at all, while the container sat there
apparently doing nothing, and I nearly tore the whole thing down. The image's own health check
allows a five-minute start period for a reason: Zulip migrates, generates secrets, compiles its
configuration and starts a dozen supervised processes before nginx answers. Let the step 7 loop
run its full ten minutes. If it still fails, run `docker compose logs --tail 60 zulip` and read
for `memcached`, whose failure that log describes worst.

## 11. Out of scope

- Do not set `CERTIFICATES`. Any value moves Zulip to 443, fighting Caddy for the certificate.
- Do not publish port 25 or set `SETTING_EMAIL_GATEWAY_PATTERN`. The incoming email gateway
  wants an MX record and a mail port on this box.
- Do not register this server for the mobile push notification service. That is an account
  with Zulip and a paid plan above ten users, not a setting.
- Do not enable LDAP, SAML or social sign-on. This install uses an email address and password.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You will install Zulip Server 12.2 on a server you already own, reachable at https://<DOMAIN>
behind Caddy. Everything below assumes you are logged into that server over SSH as a non-root
user who is in the `docker` group, with Docker and Caddy already installed and the firewall
default-deny. Replace `<DOMAIN>` with the hostname whose A record already points at the box
every time you see it.

Before you start, get relay credentials from a transactional mail provider: a host, a port, a
username and a password. Zulip mails invitations, password resets and missed-message
notifications, and with no relay configured it raises no error at all. It loads a backend that
accepts each message and discards it. A consumer mailbox is not a relay.

## 1. Preflight

That hostname becomes `SETTING_EXTERNAL_HOST`. Every invitation link is built from it, and
changing it later is a database edit, so decide now.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and
your server's IP address on the last line.

If you do not: under 4096 MB or 20 GB is the number to take seriously rather than push through.
Upstream's floor is 2 GB for the server alone and this is five containers plus an image
measured in gigabytes. If `dig +short` prints nothing, the DNS record is missing or has not
propagated; fix that before going on, because Caddy cannot get a certificate for a name that
does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/zulip /srv/zulip/backups
sudo install -d -m 700 /srv/zulip/data /srv/zulip/postgres
ls -la /srv/zulip
```

You should see: `backups` owned by your login user, and `data` and `postgres` both at mode
`drwx------` owned by `root`.

If you do not: do not chown them to yourself. The Zulip container runs as root and creates
`uploads` and `zulip-secrets.conf` under `data` itself, and the PostgreSQL image chowns its own
cluster on first start. RabbitMQ and Redis use named Docker volumes, so there is nothing to
create for them.

## 3. Secrets

Five secrets get generated on the server: the PostgreSQL, memcached, RabbitMQ and Redis
passwords the five containers use to authenticate to each other, and the Django key that seals
every session. **Do not paste the contents of `.env`, any of these values, or any command
output containing them into this chat window.** Nothing in this path needs you to. Hex is used
throughout because each value is read out of `.env` by Compose and rewritten into a config file
inside the container.

```bash
umask 077
cat > /srv/zulip/.env <<EOF
DOMAIN=<DOMAIN>
ZULIP_ADMIN_EMAIL=CHANGE_ME
ZULIP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
ZULIP_MEMCACHED_PASSWORD=$(openssl rand -hex 32)
ZULIP_RABBITMQ_PASSWORD=$(openssl rand -hex 32)
ZULIP_REDIS_PASSWORD=$(openssl rand -hex 32)
ZULIP_SECRET_KEY=$(openssl rand -hex 32)
MEMCACHED_SASL_DB=/home/memcache/memcached-sasl-db
ZULIP_EMAIL_HOST=CHANGE_ME
ZULIP_EMAIL_USER=CHANGE_ME
ZULIP_EMAIL_PASSWORD=CHANGE_ME
ZULIP_EMAIL_PORT=587
ZULIP_EMAIL_USE_TLS=True
ZULIP_EMAIL_USE_SSL=False
EOF
chmod 600 /srv/zulip/.env
umask 022
ls -l /srv/zulip/.env
```

You should see: `-rw------- 1 you you` and the path. Replace `<DOMAIN>` on the first line with
your real hostname before you run this.

If you do not: if the mode shows anything other than `-rw-------`, run `chmod 600
/srv/zulip/.env` again before continuing. If `openssl` is missing, install it and rerun the
whole block, because a half-written `.env` is worse than none.

Now fill in the four `CHANGE_ME` lines yourself:

```bash
nano /srv/zulip/.env
```

Put your own email address in `ZULIP_ADMIN_EMAIL`. Put your relay's hostname, username and
password in the three mail lines. If your relay uses implicit TLS on port 465, set
`ZULIP_EMAIL_PORT=465`, `ZULIP_EMAIL_USE_TLS=False` and `ZULIP_EMAIL_USE_SSL=True`. Save with
Ctrl-O then Enter, exit with Ctrl-X.

```bash
grep -c CHANGE_ME /srv/zulip/.env
```

You should see: `0`.

If you do not: the number is how many lines still hold the placeholder. Reopen the file and
finish. That command counts lines and never prints a value, which is why it is safe to show me
its output.

## 4. compose.yml

```bash
cat > /srv/zulip/compose.yml <<'EOF'
# Zulip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   variables ... https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   entrypoint .. https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# Five services, which is what Zulip is. Upstream's compose.yaml runs the
# same five and publishes 25, 80 and 443; this publishes one loopback port
# and never 25. Secrets ride SECRETS_* variables, which entrypoint.sh copies
# into zulip-secrets.conf. The dependency images are pinned where upstream
# floats memcached:alpine, rabbitmq:4.2 and redis:alpine, each to what those
# resolved to when digests were read on 2026-08-14, all amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  database:
    image: zulip/zulip-postgresql:14@sha256:e71ba8616fa42cdc1b248f51263d9290c29681cb8c1992eb9b498af0bb656b29
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: ${ZULIP_POSTGRES_PASSWORD}
    volumes:
      - /srv/zulip/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      retries: 12

  memcached:
    image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    restart: unless-stopped
    # SASL: Zulip authenticates as zulip@localhost, as upstream sets up.
    command:
      - "sh"
      - "-euc"
      - |
        echo 'mech_list: plain' > /home/memcache/memcached.conf
        echo "zulip@$$HOSTNAME:$$MEMCACHED_PASSWORD" > "$$MEMCACHED_SASL_PWDB"
        echo "zulip@localhost:$$MEMCACHED_PASSWORD" >> "$$MEMCACHED_SASL_PWDB"
        exec memcached -S
    environment:
      SASL_CONF_PATH: /home/memcache/memcached.conf
      MEMCACHED_SASL_PWDB: ${MEMCACHED_SASL_DB}
      MEMCACHED_PASSWORD: ${ZULIP_MEMCACHED_PASSWORD}

  rabbitmq:
    image: rabbitmq:4.2.9@sha256:0104af7ef0d2bfff20b1e84a7177320d9b990531624d6b63f9dcf82d6de3b61b
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: ${ZULIP_RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq:/var/lib/rabbitmq

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - 'exec /usr/local/bin/docker-entrypoint.sh redis-server --requirepass "$$REDIS_PASSWORD"'
    environment:
      REDIS_PASSWORD: ${ZULIP_REDIS_PASSWORD}
    volumes:
      - redis:/data

  zulip:
    image: ghcr.io/zulip/zulip-server:12.2-0@sha256:765f0ab3caa49041989132ee1879d98dbab1df7695c27e713eac1f114d167755
    container_name: zulip
    restart: unless-stopped
    environment:
      # CERTIFICATES absent means plain HTTP on 80, behind a proxy.
      TRUST_GATEWAY_IP: "True"
      SETTING_EXTERNAL_HOST: ${DOMAIN}
      SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
      SETTING_REMOTE_POSTGRES_HOST: database
      SETTING_MEMCACHED_LOCATION: memcached:11211
      SETTING_RABBITMQ_HOST: rabbitmq
      SETTING_REDIS_HOST: redis
      SETTING_EMAIL_HOST: ${ZULIP_EMAIL_HOST}
      SETTING_EMAIL_HOST_USER: ${ZULIP_EMAIL_USER}
      SETTING_EMAIL_PORT: ${ZULIP_EMAIL_PORT}
      SETTING_EMAIL_USE_TLS: ${ZULIP_EMAIL_USE_TLS}
      SETTING_EMAIL_USE_SSL: ${ZULIP_EMAIL_USE_SSL}
      ZULIP_AUTH_BACKENDS: EmailAuthBackend
      # Upstream's small-deploy override; the default costs a gigabyte more.
      CONFIG_application_server__queue_workers_multiprocess: "False"
      SECRETS_postgres_password: ${ZULIP_POSTGRES_PASSWORD}
      SECRETS_memcached_password: ${ZULIP_MEMCACHED_PASSWORD}
      SECRETS_rabbitmq_password: ${ZULIP_RABBITMQ_PASSWORD}
      SECRETS_redis_password: ${ZULIP_REDIS_PASSWORD}
      SECRETS_secret_key: ${ZULIP_SECRET_KEY}
      SECRETS_email_password: ${ZULIP_EMAIL_PASSWORD}
    volumes:
      - /srv/zulip/data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8192.
      - "127.0.0.1:8192:80"
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started

volumes:
  rabbitmq:
  redis:
EOF
cd /srv/zulip && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK`.

If you do not: a message naming a variable means that line is missing from `.env`, so go back
to step 3. A YAML error means the paste was truncated, most often in the middle of the
`memcached` command block; delete the file and paste again in one go.

## 5. Caddy and TLS

Copy the existing Caddyfile first. A syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-zulip
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zulip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zulip.readthedocs.io/projects/docker/en/latest/how-to/compose-ssl.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also SETTING_EXTERNAL_HOST in
# compose.yml, and every invitation link is built from it.

<DOMAIN> {
	# Zulip sends its own CSP and X-Frame-Options; setting either here
	# would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8192 is the loopback port compose publishes here: not a container
	# port, not open in the firewall. Caddy adds X-Forwarded-For and
	# X-Forwarded-Proto itself, which is what TRUST_GATEWAY_IP tells Zulip
	# to believe, and it carries the long poll at /json/events unaided.
	reverse_proxy 127.0.0.1:8192
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from the reload.

If you do not: restore with `sudo cp /etc/caddy/Caddyfile.before-zulip /etc/caddy/Caddyfile`
and `sudo systemctl reload caddy`, then read what validate objected to. The usual cause is
`<DOMAIN>` left literal inside the block you pasted.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, and rules for 80/tcp, 443/tcp and 443/udp and nothing else
relevant.

If you do not: if 8192 appears, remove it with `sudo ufw delete allow 8192`. It is bound to
127.0.0.1 in the compose file and must never be reachable from outside. 5432, 11211, 5672 and
6379 have no host port at all, and port 25 stays closed because this install does not run
Zulip's incoming email gateway.

## 7. Start and verify

Two moves. The first is a one-shot container that validates the configuration and migrates the
database; it fails loudly. The second starts the server; it fails slowly.

```bash
cd /srv/zulip
docker compose pull
docker compose run --rm zulip app:init
```

You should see: several gigabytes of image layers, then a long stream of configuration output
ending with `=== End Initial Configuration Phase ===`.

If you do not: an error naming a variable that was renamed in 12.x means the compose file got
edited against an older example. `Could not connect to database server` means the PostgreSQL
container is unhappy, so run `docker compose logs --tail 20 database`.

```bash
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/new/ | grep -c 'Organization creation link required'
```

You should see: the loop counting up and ending on `200`, then `{"result":"success","msg":""}`,
then `404`, then `1`.

If you do not: that `200` is worth more than it looks. The `/health` endpoint queries
PostgreSQL, round-trips a value through memcached, pings Redis and opens a channel to RabbitMQ,
so one `200` means all five services are answering. Expect several minutes of `502` and `500`
first; the image's own health check allows a five-minute start period. If the loop runs out,
`docker compose logs --tail 60 zulip` is the place to look. The `404` at the root is correct at
this point: it is the page headed `No organization found`, and no organization exists yet. The
`1` is the security check: Zulip ships with `OPEN_REALM_CREATION` off, so the public
organization creation page already refuses strangers, and there is no window in which someone
else could claim this server.

Now make the one link that lets you in. It is single-use and it is a credential, so it goes to
a file only you can read rather than onto your screen or into this chat:

```bash
umask 077
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py generate_realm_creation_link | grep -o 'https://[^[:space:]]*/new/[A-Za-z0-9]*' > /srv/zulip/realm-link.txt
umask 022
ls -l /srv/zulip/realm-link.txt
wc -l < /srv/zulip/realm-link.txt
```

You should see: `-rw-------` on the file, and `1`.

If you do not: `0` means the command printed nothing matching, so run it again without the
redirect and read the error. The link expires in seven days and is spent the first time it is
opened.

```bash
cat /srv/zulip/realm-link.txt
```

Open that URL in a browser. You should see a page headed `Create a new Zulip organization`.
Fill in your organization name, the email address you put in `ZULIP_ADMIN_EMAIL`, and a
password. Put that password in your password manager before you submit the form, not after.
The link skips email confirmation, so it works even if the relay details are wrong, which is
what the next block is for.

```bash
rm -f /srv/zulip/realm-link.txt
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/new/ | grep -c 'Organization creation link required'
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py shell -c "from zerver.models import Realm; print([(r.string_id or '(root)', r.invite_required) for r in Realm.objects.all()])"
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py send_test_email "$(grep '^ZULIP_ADMIN_EMAIL=' /srv/zulip/.env | cut -d= -f2)"
```

You should see: `200`, then `1`, then a list of pairs in which every `invite_required` reads
`True`, then a short report from the mail command ending without an exception.

If you do not: a `404` on the first line means the organization was not actually created, so go
back to the link. A `0` on the second means the public creation page is answering something
else, which is worth stopping for. A `False` in the list means somebody turned off the
invitation requirement in the browser; turn it back on under Organization settings. If the mail
command raises, the relay details in `.env` are wrong: fix them, run
`docker compose up -d --force-recreate zulip`, and try again. Check the mailbox and confirm the
two test messages arrived before you call this done. A running container is not success.

## 8. First backup and restore

```bash
cd /srv/zulip
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
sudo tar -czf /srv/zulip/backups/zulip-$(date +%F).tar.gz -C /srv/zulip compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/zulip/backups/
```

You should see: `Backup process succeeded.` from the first command, then one `.tar.gz` with a
size in megabytes at least.

If you do not: nothing is stopped here, because `pg_dump` snapshots a running database
consistently. The `postgres` directory is deliberately left out of the archive: a live cluster
copied file by file is not a backup, and the dump written inside `data/backups` is the
restorable form. If tar complains about permissions, you dropped the `sudo`.

A backup on the same disk as the data is not a backup. Run this one on your own computer, not
the server:

```bash
mkdir -p ~/backups/zulip
scp vps:/srv/zulip/backups/*.tar.gz ~/backups/zulip/
```

You should see: the file name and a transfer percentage reaching 100%.

If you do not: `vps` is the SSH alias; substitute `user@your.server` if you do not have one
configured.

To restore onto a clean box, in this order: `docker compose down`, recreate
`/srv/zulip/postgres` exactly as in step 2, untar the archive into `/srv/zulip` with `sudo` so
that `.env` is back before anything starts, `docker compose up -d database`, wait for it to report healthy,
then `docker compose run --rm zulip app:restore <filename>` naming one of the `backup-*.sql`
files now sitting in `/srv/zulip/data/backups`, and finish with `docker compose up -d`. Read
that list once at 2am and it still works. The dump is every message and account, `data/uploads`
is every file anyone shared, and `data/zulip-secrets.conf` holds the keys that let the restored
server recognise its own sessions and its own queue workers.

## 9. Updating later

New versions are listed at https://github.com/zulip/docker-zulip/releases. Upstream publishes
no floating tags at all, and the `zulip/docker-zulip` images on Docker Hub that stop at 11.6-0
are the end-of-life packaging, so the tags that matter are on ghcr.io. Take the step 8 backup
first, then edit the image line in `/srv/zulip/compose.yml` to the new tag and its digest.

```bash
cd /srv/zulip
docker compose pull
docker compose up -d
docker compose logs --tail 40 zulip
```

You should see: the new image pulling, the container recreating, and a log that settles into
normal service output after the migrations finish.

If you do not: migrations on a major version bump take minutes and the server does not answer
while they run. Re-run the `/health` check from step 7 before calling the update done, and move
one major version at a time. Upstream's stated reason for refusing floating tags is exactly
this: a major bump carries a migration worth scheduling.

## 10. What will probably go wrong

The first `docker compose up -d` looks broken for several minutes and is not. I watched
`/health` answer `502`, then `500`, then nothing at all, while the container sat there
apparently doing nothing, and I nearly tore the whole thing down. The image's own health check
allows a five-minute start period for a reason: Zulip is migrating, generating its secrets
file, compiling its configuration and starting a dozen supervised processes before nginx
answers for it. Let the loop in step 7 run its full ten minutes. If it still fails at the end,
run `docker compose logs --tail 60 zulip` and read for the word `memcached`, the one dependency
whose failure that log describes worst.

## 11. Out of scope

- Do not set `CERTIFICATES`. Caddy terminates TLS here, and any value of that variable moves
  Zulip to port 443 and puts the container in a fight with Caddy over the certificate.
- Do not publish port 25 or set `SETTING_EMAIL_GATEWAY_PATTERN`. The incoming email gateway
  wants an MX record and a mail port on this box, which is a separate decision.
- Do not register this server for the mobile push notification service. That is an account with
  Zulip and a paid plan above ten users, not a configuration change.
- Do not enable LDAP, SAML or social authentication. This install signs people in with an email
  address and a password.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Zulip Server 12.2 under ~/selfhost/zulip, answering at http://localhost:8192.

## 1. Preflight

Say this before step 2, because it decides whether they want this install at all. Zulip is a
team chat server, and this one answers at http://localhost:8192: nobody they invite can reach
it, and neither can the Zulip app on their phone. They get a real Zulip to write in and search,
on a machine they own.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Zulip needs 4096 MB of RAM available and
20 GB free on the home disk; every image publishes amd64 and arm64. On macOS and Windows give
Docker Desktop 4 GB in its settings first. Under either floor, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/zulip/data ~/selfhost/zulip/backups
ls -la ~/selfhost/zulip
```

Assert: `ls -la` shows `data` and `backups`. Nothing needs chowning here: Zulip runs as root
and makes `uploads` and `zulip-secrets.conf` under `data`, and the others take named volumes
because they chown to uids a home mount cannot grant.

## 4. Secrets

Five: the PostgreSQL, memcached, RabbitMQ and Redis passwords the containers authenticate to
each other with, plus the Django key that seals every session. Do not print them, repeat them,
or log them.

```bash
umask 077
cat > ~/selfhost/zulip/.env <<EOF
ZULIP_ADMIN_EMAIL=admin@localhost
ZULIP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
ZULIP_MEMCACHED_PASSWORD=$(openssl rand -hex 32)
ZULIP_RABBITMQ_PASSWORD=$(openssl rand -hex 32)
ZULIP_REDIS_PASSWORD=$(openssl rand -hex 32)
ZULIP_SECRET_KEY=$(openssl rand -hex 32)
MEMCACHED_SASL_DB=/home/memcache/memcached-sasl-db
EOF
chmod 600 ~/selfhost/zulip/.env
umask 022
ls -l ~/selfhost/zulip/.env
```

Assert: mode `-rw-------`. On Windows those bits are advisory and the real boundary is the
user's own account. No outgoing mail is configured, so nothing is ever sent to
`ZULIP_ADMIN_EMAIL` and the step 7 account has no password reset. Say that before the password
is chosen, not after.

## 5. compose.yml

```bash
cat > ~/selfhost/zulip/compose.yml <<'EOF'
# Zulip · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
#   variables ... https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   entrypoint .. https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# The same five services upstream runs, on the computer you are sitting at.
# The uploads-and-secrets directory is a relative bind mount so you can open
# it in Finder or Explorer; the other three take named volumes because those
# images chown directories to uids Docker Desktop cannot grant on a home
# folder. Otherwise the server file differs only in EXTERNAL_HOST carrying
# the served port, an http URI scheme, no TRUST_GATEWAY_IP, and no mail.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  database:
    image: zulip/zulip-postgresql:14@sha256:e71ba8616fa42cdc1b248f51263d9290c29681cb8c1992eb9b498af0bb656b29
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: ${ZULIP_POSTGRES_PASSWORD}
    volumes:
      - postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      retries: 12

  memcached:
    image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    restart: unless-stopped
    # SASL: Zulip authenticates as zulip@localhost, as upstream sets up.
    command:
      - "sh"
      - "-euc"
      - |
        echo 'mech_list: plain' > /home/memcache/memcached.conf
        echo "zulip@$$HOSTNAME:$$MEMCACHED_PASSWORD" > "$$MEMCACHED_SASL_PWDB"
        echo "zulip@localhost:$$MEMCACHED_PASSWORD" >> "$$MEMCACHED_SASL_PWDB"
        exec memcached -S
    environment:
      SASL_CONF_PATH: /home/memcache/memcached.conf
      MEMCACHED_SASL_PWDB: ${MEMCACHED_SASL_DB}
      MEMCACHED_PASSWORD: ${ZULIP_MEMCACHED_PASSWORD}

  rabbitmq:
    image: rabbitmq:4.2.9@sha256:0104af7ef0d2bfff20b1e84a7177320d9b990531624d6b63f9dcf82d6de3b61b
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: ${ZULIP_RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq:/var/lib/rabbitmq

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - 'exec /usr/local/bin/docker-entrypoint.sh redis-server --requirepass "$$REDIS_PASSWORD"'
    environment:
      REDIS_PASSWORD: ${ZULIP_REDIS_PASSWORD}
    volumes:
      - redis:/data

  zulip:
    image: ghcr.io/zulip/zulip-server:12.2-0@sha256:765f0ab3caa49041989132ee1879d98dbab1df7695c27e713eac1f114d167755
    container_name: zulip
    restart: unless-stopped
    environment:
      # CERTIFICATES absent means plain HTTP on 80; the host and scheme
      # below are what Zulip prints into every link.
      SETTING_EXTERNAL_HOST: localhost:8192
      SETTING_EXTERNAL_URI_SCHEME: "http://"
      SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
      SETTING_REMOTE_POSTGRES_HOST: database
      SETTING_MEMCACHED_LOCATION: memcached:11211
      SETTING_RABBITMQ_HOST: rabbitmq
      SETTING_REDIS_HOST: redis
      ZULIP_AUTH_BACKENDS: EmailAuthBackend
      # Upstream's small-deploy override; the default costs a gigabyte.
      CONFIG_application_server__queue_workers_multiprocess: "False"
      SECRETS_postgres_password: ${ZULIP_POSTGRES_PASSWORD}
      SECRETS_memcached_password: ${ZULIP_MEMCACHED_PASSWORD}
      SECRETS_rabbitmq_password: ${ZULIP_RABBITMQ_PASSWORD}
      SECRETS_redis_password: ${ZULIP_REDIS_PASSWORD}
      SECRETS_secret_key: ${ZULIP_SECRET_KEY}
    volumes:
      - ./data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    ports:
      # Loopback only: no other device on the wifi reaches 8192.
      - "127.0.0.1:8192:80"
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started

volumes:
  postgres:
  rabbitmq:
  redis:
EOF
cd ~/selfhost/zulip && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Five services, one published port, one bind mount, no secret:
every value comes as `${...}` from the `.env` beside it.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. A certificate
attests a public name nothing here has, and nothing is published beyond loopback:

```bash
grep -c '"127.0.0.1:' ~/selfhost/zulip/compose.yml
```

Assert: that prints `1`, the single published port `127.0.0.1:8192:80`. The other four publish
nothing and talk over the compose network. No phone, no laptop on the wifi and nobody on the
internet reaches this, which for a team chat server is the trade.

One consequence before step 7: Zulip runs in production mode, so its session cookie is `Secure`
with a `__Host-` prefix. Browsers treat http://localhost as trustworthy and store it anyway,
which is what makes this work without TLS. A login form that keeps returning after a correct
password means yours does not; try Chrome.

## 7. Start and verify

Upstream boots this in two moves: a one-shot container that validates and migrates, then the
server. The first fails loudly, the second slowly.

```bash
cd ~/selfhost/zulip
docker compose pull
docker compose run --rm zulip app:init
```

Assert: the last line is `=== End Initial Configuration Phase ===`. The pull is gigabytes and
the init migrates an empty database, so this is the long step. Anything else, stop and read
the output.

```bash
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8192/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8192/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8192/
curl -sS http://localhost:8192/new/ | grep -c 'Organization creation link required'
```

Assert all four and print what you got. The loop ends on `200`; that endpoint queries
PostgreSQL, round-trips memcached, pings Redis and opens a RabbitMQ channel, so one `200` is
all five answering. The body is `{"result":"success","msg":""}`. The root prints `404`, headed
`No organization found`. The grep prints `1`: `OPEN_REALM_CREATION` is off by default. If the
loop never reaches `200`, read `docker compose logs --tail 60 zulip`; if
`port is already allocated` came back, find what holds 8192 (`ss -ltnp | grep 8192` on Linux,
`lsof -nP -iTCP:8192` on macOS) and stop until the user frees it.

The way in is a single-use link made by the server:

```bash
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py generate_realm_creation_link | grep -o 'http://[^[:space:]]*/new/[A-Za-z0-9]*'
```

Assert: one line beginning `http://localhost:8192/new/`. Print it: it never leaves this machine
and expires in a week.

STOP: tell the user to open that link and complete the page headed
`Create a new Zulip organization` with their organization name, their own address, and a
password saved to their password manager first.
Do not continue until they confirm they are signed in. There is no mail here, so that password
has no reset link.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8192/
curl -sS http://localhost:8192/new/ | grep -c 'Organization creation link required'
```

Assert both. The root prints `200`, so the organization exists. The grep prints `1` again: the
link was spent, the page is still shut, and the realm is invite-only by default. A running
container is not success.

## 8. First backup and restore

`app:backup` writes a fresh dump into the data directory; the tar carries it, the uploads, the
secrets file, `.env` and compose.yml out.

```bash
cd ~/selfhost/zulip
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
if [ "$(uname -s)" = "Linux" ]; then SUDO=sudo; else SUDO=; fi
$SUDO tar -C ~/selfhost/zulip -czf ~/selfhost/zulip/backups/zulip-$(date +%F).tar.gz compose.yml .env data
ls -lh ~/selfhost/zulip/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped: `pg_dump`
snapshots a running database consistently, and the cluster volume is not copied because the
dump inside is the restorable form. The `sudo` on Linux is not optional: the container writes
into `data` as root, and only Docker Desktop maps that back to the user. Use it on restore too.

That archive is on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB
stick, and copy it there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms the filename is there. Nowhere means no backup.

To restore: `cd ~/selfhost/zulip`, `docker compose down -v`, untar the archive there so `.env`
is back first, `docker compose up -d database`, wait thirty seconds,
`docker compose run --rm zulip app:restore <filename>` naming a `backup-*.sql` file from
`data/backups`, then `docker compose up -d`. The dump is every message, `data/uploads` every
shared file, `data/zulip-secrets.conf` what lets the server know its own sessions.

## 9. Updating later

New versions: https://github.com/zulip/docker-zulip/releases. Upstream publishes no floating
tags, so an update is deliberate: back up, then change the image line to the new tag.

```bash
cd ~/selfhost/zulip
docker compose pull
docker compose up -d
docker compose logs --tail 40 zulip
```

Zulip migrates on the way up. Watch that log until it settles, re-run step 7's `/health` check,
and move one major at a time.

## 10. What will probably go wrong

The machine runs out of memory before it runs out of patience. I gave Docker Desktop its
default allocation, started all five containers, and watched the Zulip container get killed
twice during migrations with nothing in the log but a truncated line, which reads exactly like
a crash and is not one. Open Docker Desktop's resources, give it 4 GB, and run
`docker compose up -d` again. The same shortage shows up a second way, as a first boot that
seems to hang: ten minutes is normal, so let the step 7 loop finish.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not configure outgoing mail. Without a relay Zulip drops its mail quietly.
- Do not rebind 8192 to 0.0.0.0 for a phone on the wifi. That puts a chat server on every
  network this machine joins.
- Do not register for the mobile push service. A server nothing can reach has nothing to push.
````

## docker-compose.yml

```yaml
# Zulip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   variables ... https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   entrypoint .. https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# Five services, which is what Zulip is. Upstream's compose.yaml runs the
# same five and publishes 25, 80 and 443; this publishes one loopback port
# and never 25. Secrets ride SECRETS_* variables, which entrypoint.sh copies
# into zulip-secrets.conf. The dependency images are pinned where upstream
# floats memcached:alpine, rabbitmq:4.2 and redis:alpine, each to what those
# resolved to when digests were read on 2026-08-14, all amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  database:
    image: zulip/zulip-postgresql:14@sha256:e71ba8616fa42cdc1b248f51263d9290c29681cb8c1992eb9b498af0bb656b29
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: ${ZULIP_POSTGRES_PASSWORD}
    volumes:
      - /srv/zulip/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      retries: 12

  memcached:
    image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    restart: unless-stopped
    # SASL: Zulip authenticates as zulip@localhost, as upstream sets up.
    command:
      - "sh"
      - "-euc"
      - |
        echo 'mech_list: plain' > /home/memcache/memcached.conf
        echo "zulip@$$HOSTNAME:$$MEMCACHED_PASSWORD" > "$$MEMCACHED_SASL_PWDB"
        echo "zulip@localhost:$$MEMCACHED_PASSWORD" >> "$$MEMCACHED_SASL_PWDB"
        exec memcached -S
    environment:
      SASL_CONF_PATH: /home/memcache/memcached.conf
      MEMCACHED_SASL_PWDB: ${MEMCACHED_SASL_DB}
      MEMCACHED_PASSWORD: ${ZULIP_MEMCACHED_PASSWORD}

  rabbitmq:
    image: rabbitmq:4.2.9@sha256:0104af7ef0d2bfff20b1e84a7177320d9b990531624d6b63f9dcf82d6de3b61b
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: ${ZULIP_RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq:/var/lib/rabbitmq

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - 'exec /usr/local/bin/docker-entrypoint.sh redis-server --requirepass "$$REDIS_PASSWORD"'
    environment:
      REDIS_PASSWORD: ${ZULIP_REDIS_PASSWORD}
    volumes:
      - redis:/data

  zulip:
    image: ghcr.io/zulip/zulip-server:12.2-0@sha256:765f0ab3caa49041989132ee1879d98dbab1df7695c27e713eac1f114d167755
    container_name: zulip
    restart: unless-stopped
    environment:
      # CERTIFICATES absent means plain HTTP on 80, behind a proxy.
      TRUST_GATEWAY_IP: "True"
      SETTING_EXTERNAL_HOST: ${DOMAIN}
      SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
      SETTING_REMOTE_POSTGRES_HOST: database
      SETTING_MEMCACHED_LOCATION: memcached:11211
      SETTING_RABBITMQ_HOST: rabbitmq
      SETTING_REDIS_HOST: redis
      SETTING_EMAIL_HOST: ${ZULIP_EMAIL_HOST}
      SETTING_EMAIL_HOST_USER: ${ZULIP_EMAIL_USER}
      SETTING_EMAIL_PORT: ${ZULIP_EMAIL_PORT}
      SETTING_EMAIL_USE_TLS: ${ZULIP_EMAIL_USE_TLS}
      SETTING_EMAIL_USE_SSL: ${ZULIP_EMAIL_USE_SSL}
      ZULIP_AUTH_BACKENDS: EmailAuthBackend
      # Upstream's small-deploy override; the default costs a gigabyte more.
      CONFIG_application_server__queue_workers_multiprocess: "False"
      SECRETS_postgres_password: ${ZULIP_POSTGRES_PASSWORD}
      SECRETS_memcached_password: ${ZULIP_MEMCACHED_PASSWORD}
      SECRETS_rabbitmq_password: ${ZULIP_RABBITMQ_PASSWORD}
      SECRETS_redis_password: ${ZULIP_REDIS_PASSWORD}
      SECRETS_secret_key: ${ZULIP_SECRET_KEY}
      SECRETS_email_password: ${ZULIP_EMAIL_PASSWORD}
    volumes:
      - /srv/zulip/data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8192.
      - "127.0.0.1:8192:80"
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started

volumes:
  rabbitmq:
  redis:
```

## compose.local.yml

```yaml
# Zulip · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
#   variables ... https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   entrypoint .. https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# The same five services upstream runs, on the computer you are sitting at.
# The uploads-and-secrets directory is a relative bind mount so you can open
# it in Finder or Explorer; the other three take named volumes because those
# images chown directories to uids Docker Desktop cannot grant on a home
# folder. Otherwise the server file differs only in EXTERNAL_HOST carrying
# the served port, an http URI scheme, no TRUST_GATEWAY_IP, and no mail.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  database:
    image: zulip/zulip-postgresql:14@sha256:e71ba8616fa42cdc1b248f51263d9290c29681cb8c1992eb9b498af0bb656b29
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: ${ZULIP_POSTGRES_PASSWORD}
    volumes:
      - postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      retries: 12

  memcached:
    image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    restart: unless-stopped
    # SASL: Zulip authenticates as zulip@localhost, as upstream sets up.
    command:
      - "sh"
      - "-euc"
      - |
        echo 'mech_list: plain' > /home/memcache/memcached.conf
        echo "zulip@$$HOSTNAME:$$MEMCACHED_PASSWORD" > "$$MEMCACHED_SASL_PWDB"
        echo "zulip@localhost:$$MEMCACHED_PASSWORD" >> "$$MEMCACHED_SASL_PWDB"
        exec memcached -S
    environment:
      SASL_CONF_PATH: /home/memcache/memcached.conf
      MEMCACHED_SASL_PWDB: ${MEMCACHED_SASL_DB}
      MEMCACHED_PASSWORD: ${ZULIP_MEMCACHED_PASSWORD}

  rabbitmq:
    image: rabbitmq:4.2.9@sha256:0104af7ef0d2bfff20b1e84a7177320d9b990531624d6b63f9dcf82d6de3b61b
    hostname: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: ${ZULIP_RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq:/var/lib/rabbitmq

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - 'exec /usr/local/bin/docker-entrypoint.sh redis-server --requirepass "$$REDIS_PASSWORD"'
    environment:
      REDIS_PASSWORD: ${ZULIP_REDIS_PASSWORD}
    volumes:
      - redis:/data

  zulip:
    image: ghcr.io/zulip/zulip-server:12.2-0@sha256:765f0ab3caa49041989132ee1879d98dbab1df7695c27e713eac1f114d167755
    container_name: zulip
    restart: unless-stopped
    environment:
      # CERTIFICATES absent means plain HTTP on 80; the host and scheme
      # below are what Zulip prints into every link.
      SETTING_EXTERNAL_HOST: localhost:8192
      SETTING_EXTERNAL_URI_SCHEME: "http://"
      SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
      SETTING_REMOTE_POSTGRES_HOST: database
      SETTING_MEMCACHED_LOCATION: memcached:11211
      SETTING_RABBITMQ_HOST: rabbitmq
      SETTING_REDIS_HOST: redis
      ZULIP_AUTH_BACKENDS: EmailAuthBackend
      # Upstream's small-deploy override; the default costs a gigabyte.
      CONFIG_application_server__queue_workers_multiprocess: "False"
      SECRETS_postgres_password: ${ZULIP_POSTGRES_PASSWORD}
      SECRETS_memcached_password: ${ZULIP_MEMCACHED_PASSWORD}
      SECRETS_rabbitmq_password: ${ZULIP_RABBITMQ_PASSWORD}
      SECRETS_redis_password: ${ZULIP_REDIS_PASSWORD}
      SECRETS_secret_key: ${ZULIP_SECRET_KEY}
    volumes:
      - ./data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    ports:
      # Loopback only: no other device on the wifi reaches 8192.
      - "127.0.0.1:8192:80"
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started

volumes:
  postgres:
  rabbitmq:
  redis:
```

## Caddyfile

```text
# Zulip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zulip.readthedocs.io/projects/docker/en/latest/how-to/compose-ssl.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also SETTING_EXTERNAL_HOST in
# compose.yml, and every invitation link is built from it.

<DOMAIN> {
	# Zulip sends its own CSP and X-Frame-Options; setting either here
	# would overwrite the application's answer.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8192 is the loopback port compose publishes here: not a container
	# port, not open in the firewall. Caddy adds X-Forwarded-For and
	# X-Forwarded-Proto itself, which is what TRUST_GATEWAY_IP tells Zulip
	# to believe, and it carries the long poll at /json/events unaided.
	reverse_proxy 127.0.0.1:8192
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Zulip · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=chat.example.com ADMIN_EMAIL=you@example.com \
#     SMTP_HOST=smtp.example.com SMTP_USER=noreply@example.com ./install.sh
#
# SMTP_PASSWORD is read from the terminal if it is not already set, so the
# relay password never has to appear in your shell history.
#
# Authored by caniselfhostit from the upstream documentation:
#   https://zulip.readthedocs.io/projects/docker/en/latest/how-to/compose-getting-started.html
#   https://zulip.readthedocs.io/projects/docker/en/latest/reference/environment-vars.html
#   https://zulip.readthedocs.io/projects/docker/en/latest/how-to/compose-ssl.html
#   https://github.com/zulip/docker-zulip/blob/12.2-0/entrypoint.sh
#
# Five secrets are generated here, on this machine: the PostgreSQL,
# memcached, RabbitMQ and Redis passwords the containers authenticate to
# each other with, and the Django key that seals every session. They go into
# /srv/zulip/.env with mode 600 and are never printed.
#
# This script cannot create the first organization, because only a human
# with a browser can. It generates the single-use link and leaves it in a
# mode-600 file for you to read; the closing summary says where.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/zulip}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USE_TLS="${SMTP_USE_TLS:-True}"
SMTP_USE_SSL="${SMTP_USE_SSL:-False}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. chat.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address Zulip should send error reports to"
[ -n "$SMTP_HOST" ] || die "set SMTP_HOST to your transactional relay. Zulip drops mail silently without one."
[ -n "$SMTP_USER" ] || die "set SMTP_USER to the username your relay expects"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; five containers plus Zulip want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}')" || resolved=""
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

if [ -z "${SMTP_PASSWORD:-}" ]; then
	printf 'Relay password for %s (not echoed): ' "$SMTP_USER" >&2
	read -rs SMTP_PASSWORD
	printf '\n' >&2
fi
[ -n "$SMTP_PASSWORD" ] || die "the relay password is empty"

# --- 2. Lay the files out ----------------------------------------------------
#
# The Zulip container runs as root and creates uploads/ and
# zulip-secrets.conf under data/ itself; PostgreSQL chowns its own cluster.
# RabbitMQ and Redis use named volumes and need nothing here.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/data" "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the five secrets, on the server -----------------------------
#
# Hex throughout: each value is read out of .env by Compose and rewritten
# into a config file inside the container, and a $ or a quote in the middle
# of that trip is an outage nobody diagnoses quickly. Read them later with
#   grep ZULIP_ /srv/zulip/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DOMAIN=${DOMAIN_HOST}
		ZULIP_ADMIN_EMAIL=${ADMIN_EMAIL}
		ZULIP_POSTGRES_PASSWORD=$(openssl rand -hex 32)
		ZULIP_MEMCACHED_PASSWORD=$(openssl rand -hex 32)
		ZULIP_RABBITMQ_PASSWORD=$(openssl rand -hex 32)
		ZULIP_REDIS_PASSWORD=$(openssl rand -hex 32)
		ZULIP_SECRET_KEY=$(openssl rand -hex 32)
		MEMCACHED_SASL_DB=/home/memcache/memcached-sasl-db
		ZULIP_EMAIL_HOST=${SMTP_HOST}
		ZULIP_EMAIL_USER=${SMTP_USER}
		ZULIP_EMAIL_PASSWORD=${SMTP_PASSWORD}
		ZULIP_EMAIL_PORT=${SMTP_PORT}
		ZULIP_EMAIL_USE_TLS=${SMTP_USE_TLS}
		ZULIP_EMAIL_USE_SSL=${SMTP_USE_SSL}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-zulip"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and none of the five service ports is one of them ----

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8192, 5432, 11211, 5672, 6379 and 25 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Boot it, upstream's way ----------------------------------------------
#
# app:init validates the configuration and migrates the database in a
# one-shot container that fails loudly, before the server that fails slowly.

docker compose pull
if ! docker compose run --rm zulip app:init | tee /tmp/zulip-init.log; then
	die "app:init exited non-zero. Read /tmp/zulip-init.log before starting the server."
fi
grep -q '=== End Initial Configuration Phase ===' /tmp/zulip-init.log \
	|| die "app:init did not reach the end of the configuration phase. Read /tmp/zulip-init.log."
rm -f /tmp/zulip-init.log

docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health (first boot takes minutes)"
code=""
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || echo "000")"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "$code" = "200" ] || die "/health answered ${code}. Check: docker compose logs --tail 60 zulip"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"result":"success"' \
	|| die "/health answered 200 without a success body. Check: docker compose logs --tail 60 zulip"

# The public organization creation page must already be refusing strangers:
# Zulip ships with OPEN_REALM_CREATION off, and nothing here turns it on.
curl -sS "https://${DOMAIN_HOST}/new/" | grep -q 'Organization creation link required' \
	|| die "the organization creation page is not refusing anonymous callers. Stop and investigate."

# --- 7. The one-time link only a human can use -------------------------------

umask 077
docker compose exec -T -u zulip zulip /home/zulip/deployments/current/manage.py generate_realm_creation_link \
	| grep -o "https://[^[:space:]]*/new/[A-Za-z0-9]*" > "$APP_DIR/realm-link.txt"
umask 022
[ "$(wc -l < "$APP_DIR/realm-link.txt")" -eq 1 ] \
	|| die "no organization creation link was produced. Check: docker compose logs --tail 40 zulip"

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
sudo tar -czf "$APP_DIR/backups/zulip-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env data -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/zulip-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Zulip is answering at https://${DOMAIN_HOST}/health

	  1. Read your one-time organization creation link:
	       cat ${APP_DIR}/realm-link.txt
	     Open it, fill in the page headed "Create a new Zulip organization",
	     and save that password in your password manager before you submit.
	     The link is single-use and expires in seven days. Delete the file
	     once you are in:  rm -f ${APP_DIR}/realm-link.txt
	  2. Then prove the front door is still shut. This has to print 1:
	       curl -sS https://${DOMAIN_HOST}/new/ | grep -c 'Organization creation link required'
	  3. Then prove mail leaves the box:
	       docker compose exec -T -u zulip zulip \\
	         /home/zulip/deployments/current/manage.py send_test_email ${ADMIN_EMAIL}
	     If that raises, the relay details in ${APP_DIR}/.env are wrong.
	  4. Your five generated secrets are in ${APP_DIR}/.env, mode 600. Read
	     them with:  grep ZULIP_ ${APP_DIR}/.env
	  5. First backup written to ${APP_DIR}/backups. It predates your
	     organization, so take another once you are in. It sits on the same
	     disk as the data, which is not a backup: copy it off tonight.

DONE
```

## Also evaluated

Ranked below Zulip for this swap. The prompts above install Zulip only.

- **Mattermost** — Channels, threads and file sharing for a team, on a server you own, with no per-seat meter and no ninety-day history cliff. The right answer if your team will not adopt topics. Mattermost is channel-shaped in the way Slack is channel-shaped, with threads as a reply affordance rather than the organising principle, and that is a real trade against Twist rather than a lesser version of it: you get the habit everyone already has, and you lose the thing Twist exists to sell. It is also two containers rather than five and needs no relay to work, so it is the lighter install by some distance. Against it here: Team Edition is amd64 only, and its free tier stops short of SSO, guest accounts and compliance export in a way Zulip's does not.

The page this mirrors: https://caniselfhostit.com/self-host/twist/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
