How to Deploy Zulip on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Zulip, the topic-threaded team chat, with the official docker-zulip Compose stack — pinned release, generated secrets, a loopback-only HTTP port, and Caddy terminating HTTPS in front.
- A VPS with at least 2 vCPU / 4 GB RAM — Zulip idles above 3 GB on its own
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server, e.g. chat.example.net — Zulip needs a hostname, not an IP
- Docker Engine + Compose installed (see the base guide below)
- git on the server — the official deployment is a repository you clone
What Zulip is
Zulip is an open-source (Apache-2.0) team chat server. Its defining idea is that every message lives in a topic inside a channel, so a channel is a list of named threads rather than one scrolling river. That makes it slower to learn than Slack and much easier to catch up on after a day away. It ships web, desktop and mobile apps.
Under the hood it is a Django application backed by PostgreSQL, RabbitMQ, memcached and Redis. You don't wire those together yourself: the official docker-zulip repository ships a Compose file that runs all five containers, and that is the path this guide follows.
If you're still choosing a chat server, Mattermost is the closest Slack look-alike, and Mattermost vs Rocket.Chat covers the other two big options. Zulip is the pick when your team argues in threads and wants them to stay findable.
Three things to know before you install
- Zulip will not start on the example defaults. The override file ships
with
zulip.example.comas the hostname andzulip-admin@example.comas the admin address. You must replace both. - Zulip needs a hostname.
SETTING_EXTERNAL_HOSTis the name users type into their browser — upstream is explicit that it should be a domain, not an IP address. - Zulip only works over HTTPS, but the container serves plain HTTP by
default. With
CERTIFICATESunset, the container serves unencrypted HTTP on port 80 and expects a TLS-terminating proxy in front. Set it toself-signed,certbotormanualand the container terminates TLS itself on port 443 instead — never both. This guide uses the proxy mode, with Caddy in front.
Server sizing
Zulip is the heaviest chat server in our catalog. Five containers — the app (which runs its own nginx, Django workers and a Tornado real-time server), Postgres, RabbitMQ, memcached and Redis — add up fast:
- Measured idle RAM: about 3.2 GB (3,259 MB) for the whole stack on our test box, with no users and no traffic.
- 4 GB RAM / 2 vCPU is the minimum we'd run it on, and it leaves almost nothing for anything else on the box.
- 8 GB RAM is the comfortable size for a real team, and what we tested on.
Disk: the images and a fresh install took about 5.1 GB in our measurement. Growth after that is user uploads plus the database dumps Zulip writes every night (kept indefinitely — see Backups), so start with 40 GB+.
Paid link — we earn a commission if you shop through it.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, with a
non-root user and ufw. If not, work through
Docker & Compose on Ubuntu first.
Open SSH and the reverse-proxy ports. Zulip's own port stays on loopback:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
ufw alone does not protect a Docker port. Docker writes its own iptables
rules for published ports, and they are consulted before ufw's. The stock
docker-zulip compose.yaml publishes ports 25, 80 and 443 on every interface;
the override below replaces that with a single loopback-only port.
Install Zulip
Clone the repository and pin a release
The git tag of the repository is the version of record: each tag's
compose.yaml references the matching image (ghcr.io/zulip/zulip-server:12.3-0
at the time of writing). Upstream publishes no floating tags, so check out the
release explicitly:
cd ~
[ -d ~/docker-zulip/.git ] || git clone https://github.com/zulip/docker-zulip.git
cd ~/docker-zulip
git fetch --tags
git checkout -B release 12.3-0
Check the releases page for a newer tag and use that instead.
Generate the six secrets
The Compose file reads six secrets from a .env file next to it, as
ZULIP__-prefixed variables. Generate them once — do not regenerate them on
an existing install; changing the Postgres password on a running deployment
needs a manual ALTER ROLE:
cd ~/docker-zulip
if [ ! -f .env ]; then
for s in POSTGRES_PASSWORD MEMCACHED_PASSWORD RABBITMQ_PASSWORD REDIS_PASSWORD SECRET_KEY EMAIL_PASSWORD; do
echo "ZULIP__$s=$(openssl rand -hex 24)" >> .env
done
chmod 600 .env
fi
ZULIP__EMAIL_PASSWORD is a placeholder until you configure outgoing mail;
replace it with your SMTP password then.
Write compose.override.yaml
Upstream's flow is cp compose.override.yaml.example compose.override.yaml and
then edit. The example is worth reading — every optional setting is in it,
commented out — but for this deployment we write the override directly. Change
the two variables on the first lines to your hostname and admin email:
cd ~/docker-zulip
ZULIP_HOST=chat.example.net
ZULIP_ADMIN=admin@example.net
cat > compose.override.yaml <<YAML
secrets:
zulip__postgres_password:
environment: "ZULIP__POSTGRES_PASSWORD"
zulip__memcached_password:
environment: "ZULIP__MEMCACHED_PASSWORD"
zulip__rabbitmq_password:
environment: "ZULIP__RABBITMQ_PASSWORD"
zulip__redis_password:
environment: "ZULIP__REDIS_PASSWORD"
zulip__secret_key:
environment: "ZULIP__SECRET_KEY"
zulip__email_password:
environment: "ZULIP__EMAIL_PASSWORD"
services:
zulip:
environment:
SETTING_EXTERNAL_HOST: "$ZULIP_HOST"
SETTING_ZULIP_ADMINISTRATOR: "$ZULIP_ADMIN"
# CERTIFICATES left unset: plain HTTP on port 80, TLS is Caddy's job.
# Trust X-Forwarded-For/-Proto from the Docker gateway (host-side Caddy).
TRUST_GATEWAY_IP: "True"
# Replace (not append to) the stock 25/80/443 mappings.
ports: !override
- name: http
target: 80
published: 8080
host_ip: 127.0.0.1
app_protocol: http
YAML
Two lines deserve an explanation:
ports: !override. Compose appends list entries from an override file to the base file's, so a plainports:block would still try to bind host port 80 — and fail once Caddy owns it (Bind for 0.0.0.0:80 failed: port is already allocated). The!overridetag replaces the list outright. Port 25 (the incoming email gateway) is dropped; add it back only if you set that up.TRUST_GATEWAY_IP. Zulip ignoresX-Forwarded-*headers from sources it doesn't trust, because trusting everyone would let clients spoof their IP and claim HTTPS. Caddy on the host reaches the container through the Docker gateway, so trusting the gateway IP is upstream's documented shortcut. The explicit alternative isLOADBALANCER_IPSwith the proxy's IP or CIDR range.
Initialise and start
The first run boots the dependencies, validates the configuration and creates
the database. It takes a minute or two and should end with
=== End Initial Configuration Phase ===:
cd ~/docker-zulip
docker compose pull
docker compose run --rm zulip app:init
docker compose up -d --wait
--wait blocks until the container's health check passes, which can take a few
minutes on first boot (the image allows a 300-second start period). Then confirm
Zulip answers the way Caddy will talk to it — your hostname in Host, https
in X-Forwarded-Proto:
cd ~/docker-zulip
docker compose ps
curl -fsS -H "Host: chat.example.net" -H "X-Forwarded-Proto: https" \
http://127.0.0.1:8080/health
HTTPS and domain with Caddy
Point an A record (and AAAA if you have IPv6) for your hostname at the
server. With Caddy installed per
Automatic HTTPS with Caddy, the site block
is:
chat.example.net {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
Caddy passes the client's Host header through and sets X-Forwarded-For and
X-Forwarded-Proto itself — the three things Zulip's reverse-proxy docs require.
The fourth is not interfering with long-polling: Zulip pushes events to browsers
over requests that stay open for minutes, and upstream warns that a buffering
proxy produces occasional 502s. flush_interval -1 turns buffering off. Reload
Caddy and the certificate is issued on the first request.
Create your organization
Zulip has no signup page on a fresh install. You generate a one-time link:
cd ~/docker-zulip
docker compose exec -T -u zulip zulip \
/home/zulip/deployments/current/manage.py generate_realm_creation_link
The repository also ships a ./manage.py helper that wraps the same exec —
fine in an interactive shell. Open the printed link over HTTPS, create the
organization and your owner account, then invite your team from the settings.
Securing it
- Outgoing email. Invitations, password resets and notification digests
are email. Set
SETTING_EMAIL_HOST,SETTING_EMAIL_HOST_USER,SETTING_EMAIL_PORTandSETTING_EMAIL_USE_TLSin the override (the example file has the block), put the SMTP password inZULIP__EMAIL_PASSWORDin.env, anddocker compose up -d --wait. - Keep the port on loopback. Only Caddy should reach 8080. If you ever
switch to
CERTIFICATES: certbot, drop Caddy and publish 80/443 instead — don't run both. - Authentication. Email/password is the default.
ZULIP_AUTH_BACKENDS(for exampleEmailAuthBackend,GoogleAuthBackend) adds SSO; if you run an identity provider such as Keycloak, wire it in there. - Mobile push notifications go through Zulip's push service: set
SETTING_ZULIP_SERVICE_PUSH_NOTIFICATIONS: Trueand run./manage.py register_server.
Backups
Zulip's persistent state lives in the zulip volume mounted at /data:
uploads, configuration, secrets and database dumps under /data/backups/. The
live Postgres volume is not safe to copy while running; the dump is the
restorable form. Zulip already writes one every night at 03:30 UTC
(AUTO_BACKUP_ENABLED, AUTO_BACKUP_INTERVAL), and keeps them indefinitely, so
prune old ones.
Refresh the dump, then snapshot the whole volume:
cd ~/docker-zulip
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
docker compose run --rm -v zulip:/data -v "$(pwd)":/backup zulip \
tar czf /backup/zulip-backup-$(date +%F).tar.gz -C /data .
ls -lh zulip-backup-*.tar.gz
Copy the archive and .env + compose.override.yaml off the box. Restoring
means untarring into a fresh volume, then app:restore with one of the
backup-*.sql files:
cd ~/docker-zulip
docker compose down -v
docker compose run --rm --no-deps -v zulip:/data -v "$(pwd)":/backup zulip \
tar xzf /backup/zulip-backup-YYYY-MM-DD.tar.gz -C /data
docker compose run --rm zulip app:restore backup-FILENAME.sql
docker compose up -d --wait
Upgrades
Upgrading means checking out a newer tag, which updates the image reference in
compose.yaml; the new container runs database migrations on first boot.
compose.override.yaml is gitignored, so the checkout leaves your settings
alone. Take a backup first, then replace 12.3-0 with the target tag:
cd ~/docker-zulip
docker compose exec -T zulip /sbin/entrypoint.sh app:backup
git fetch --tags
git checkout -B release 12.3-0
docker compose pull
docker compose up -d --wait
Upstream recommends staying on the latest minor release of your major series.
After an upgrade, diff compose.override.yaml.example compose.override.yaml
shows any new options the release added. Downgrading needs a manual schema
migration — the backup is your rollback.
Troubleshooting
app:init doesn't end with === End Initial Configuration Phase ===. Read
its output: an example.com hostname or admin address left in the override,
or a missing .env entry, are the usual causes.
Bind for 0.0.0.0:80 failed: port is already allocated. The ports block
is missing its !override tag, so Compose still publishes the stock ports.
CSRF errors on login, or every user shows the same IP. Zulip isn't trusting
the proxy's X-Forwarded-* headers. Check TRUST_GATEWAY_IP (or
LOADBALANCER_IPS) is set and recreate the container with
docker compose up -d --wait.
Occasional 502 errors. The proxy is
buffering or timing out Zulip's long-polling requests. Keep
flush_interval -1; with any other proxy, disable buffering and set a read
timeout well above 60 seconds.
The container stays starting for minutes. Normal on first boot. To see
why it's slow:
cd ~/docker-zulip
docker compose logs -f zulip
The box swaps or OOM-kills containers. 4 GB is tight for a 3.2 GB idle footprint. Move to 8 GB before blaming Zulip.
Verification + next steps
You're done when you can: load https://chat.example.net over a valid
certificate, log in to the organization you created, receive an invitation
email on a real inbox, post in a topic from two browsers and see it appear in
the other without refreshing, and find a fresh .tar.gz backup off the box.
From there: set up mobile push, connect SSO, and schedule the backup block with cron. For uptime alerts on the new server, Gatus on a separate small VPS is a good fit; for picking hardware, see Best VPS for Self-Hosting.