Skip to content

How to Deploy Zulip on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Sep 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-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.

Before you start
  • 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
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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

  1. Zulip will not start on the example defaults. The override file ships with zulip.example.com as the hostname and zulip-admin@example.com as the admin address. You must replace both.
  2. Zulip needs a hostname. SETTING_EXTERNAL_HOST is the name users type into their browser — upstream is explicit that it should be a domain, not an IP address.
  3. Zulip only works over HTTPS, but the container serves plain HTTP by default. With CERTIFICATES unset, the container serves unencrypted HTTP on port 80 and expects a TLS-terminating proxy in front. Set it to self-signed, certbot or manual and 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+.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

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 plain ports: 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 !override tag 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 ignores X-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 is LOADBALANCER_IPS with 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_PORT and SETTING_EMAIL_USE_TLS in the override (the example file has the block), put the SMTP password in ZULIP__EMAIL_PASSWORD in .env, and docker 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 example EmailAuthBackend,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: True and 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.

Next steps

How to self-host Zulip →More self-hosted team chat tools →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.