How to Self-Host Zulip with Docker Compose

What Is Zulip?

Zulip is a self-hosted team chat platform built around topic-based threading. Every message belongs to a stream (like a channel) and a topic within that stream, so conversations stay organized even when dozens happen in parallel. This is fundamentally different from Slack and Teams where channels are a single chronological feed that becomes unreadable at scale. Zulip replaces Slack, Microsoft Teams, and Discord for teams that value async communication. It is fully open source (Apache 2.0), used by hundreds of open-source projects including the Lean theorem prover community and FOSS contributors who need structured discussion across time zones. GitHub

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • 2 GB of free RAM minimum, plus 2 GB of swap configured (4 GB RAM recommended for 50+ users)
  • 10 GB of free disk space
  • A domain name pointed at your server (required for SSL and email)
  • Standard Docker only — Zulip does not support rootless Docker (the container requires ulimit settings that rootless mode cannot provide)

Configure swap if you have not already:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Docker Compose Configuration

Zulip uses a five-service stack: the Zulip application server, PostgreSQL (a custom image with full-text search extensions), Memcached, RabbitMQ, and Redis.

Create a .env file with your secrets and settings:

# .env — Zulip secrets and configuration
# CHANGE ALL VALUES BELOW before starting

# Your public domain for Zulip
SETTING_EXTERNAL_HOST=zulip.example.com

# Admin email — receives server notifications and becomes the first admin
SETTING_ZULIP_ADMINISTRATOR=[email protected]

# Secret key — generate with: openssl rand -hex 32
SECRET_KEY=REPLACE_WITH_64_CHAR_HEX_STRING

# Email / SMTP settings
SETTING_EMAIL_HOST=smtp.example.com
SETTING_EMAIL_HOST_USER=[email protected]
SETTING_EMAIL_PORT=587
SETTING_EMAIL_USE_TLS=True
SETTING_EMAIL_USE_SSL=False
SECRETS_email_password=your-smtp-password
SETTING_NOREPLY_EMAIL_ADDRESS=[email protected]
SETTING_ADD_TOKENS_TO_NOREPLY_ADDRESS=True

# PostgreSQL
SECRETS_postgres_password=REPLACE_WITH_STRONG_DB_PASSWORD

# RabbitMQ
SECRETS_rabbitmq_password=REPLACE_WITH_STRONG_RABBITMQ_PASSWORD

# Memcached
SECRETS_memcached_password=REPLACE_WITH_STRONG_MEMCACHED_PASSWORD

# Redis
SECRETS_redis_password=REPLACE_WITH_STRONG_REDIS_PASSWORD

Create a compose.yaml file:

services:
  zulip:
    image: ghcr.io/zulip/zulip-server:11.5-2
    container_name: zulip
    restart: unless-stopped
    ports:
      - "80:80"       # HTTP (redirects to HTTPS)
      - "443:443"     # HTTPS
    environment:
      DISABLE_HTTPS: "false"
      SETTING_EXTERNAL_HOST: "${SETTING_EXTERNAL_HOST}"
      SETTING_ZULIP_ADMINISTRATOR: "${SETTING_ZULIP_ADMINISTRATOR}"
      SSL_CERTIFICATE_GENERATION: "self-signed"
      SETTING_EMAIL_HOST: "${SETTING_EMAIL_HOST}"
      SETTING_EMAIL_HOST_USER: "${SETTING_EMAIL_HOST_USER}"
      SETTING_EMAIL_PORT: "${SETTING_EMAIL_PORT}"
      SETTING_EMAIL_USE_TLS: "${SETTING_EMAIL_USE_TLS}"
      SETTING_EMAIL_USE_SSL: "${SETTING_EMAIL_USE_SSL}"
      SECRETS_email_password: "${SECRETS_email_password}"
      SETTING_NOREPLY_EMAIL_ADDRESS: "${SETTING_NOREPLY_EMAIL_ADDRESS}"
      SETTING_ADD_TOKENS_TO_NOREPLY_ADDRESS: "${SETTING_ADD_TOKENS_TO_NOREPLY_ADDRESS}"
      SECRETS_secret_key: "${SECRET_KEY}"
      SECRETS_postgres_password: "${SECRETS_postgres_password}"
      SECRETS_rabbitmq_password: "${SECRETS_rabbitmq_password}"
      SECRETS_memcached_password: "${SECRETS_memcached_password}"
      SECRETS_redis_password: "${SECRETS_redis_password}"
      SETTING_MEMCACHED_LOCATION: "memcached:11211"
      SETTING_RABBITMQ_HOST: "rabbitmq"
      SETTING_REDIS_HOST: "redis"
      SETTING_POSTGRES_HOST: "database"
    volumes:
      - zulip_data:/data
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    depends_on:
      database:
        condition: service_healthy
      memcached:
        condition: service_started
      rabbitmq:
        condition: service_started
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD-SHELL", "curl -fk https://localhost/api/v1/server_settings || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s

  database:
    image: zulip/zulip-postgresql:14
    container_name: zulip-database
    restart: unless-stopped
    environment:
      POSTGRES_DB: zulip
      POSTGRES_USER: zulip
      POSTGRES_PASSWORD: "${SECRETS_postgres_password}"
    volumes:
      - zulip_postgresql:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zulip -d zulip"]
      interval: 10s
      timeout: 5s
      retries: 5

  memcached:
    image: memcached:1.6-alpine
    container_name: zulip-memcached
    restart: unless-stopped
    command:
      - "sh"
      - "-euc"
      - |
        echo '${SECRETS_memcached_password}' > /tmp/memcached-sasl
        exec memcached -S
    healthcheck:
      test: ["CMD-SHELL", "echo stats | nc localhost 11211 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3

  rabbitmq:
    image: rabbitmq:4.0-alpine
    container_name: zulip-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: zulip
      RABBITMQ_DEFAULT_PASS: "${SECRETS_rabbitmq_password}"
    volumes:
      - zulip_rabbitmq:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    container_name: zulip-redis
    restart: unless-stopped
    command:
      - "sh"
      - "-c"
      - "exec redis-server --requirepass '${SECRETS_redis_password}'"
    volumes:
      - zulip_redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${SECRETS_redis_password}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  zulip_data:
  zulip_postgresql:
  zulip_rabbitmq:
  zulip_redis:

Start the stack:

docker compose up -d

First startup takes 2-3 minutes. The Zulip container runs database migrations and compiles static assets. Monitor progress with:

docker compose logs -f zulip

Wait until you see Zulip is ready or nginx: started in the logs before accessing the web UI.

Initial Setup

  1. Open https://your-domain in your browser (accept the self-signed certificate warning if you have not configured a real certificate yet)
  2. Click Register to create the first account using the email address you set as SETTING_ZULIP_ADMINISTRATOR
  3. This first account becomes the Organization Owner with full admin privileges
  4. Complete the organization setup wizard — name your organization, set default settings, and choose your authentication methods
  5. Invite team members from Settings → Users → Invite users or share the registration link

To create an admin account from the command line (useful if email is not configured yet):

docker compose exec zulip /home/zulip/deployments/current/manage.py createsuperuser

Configuration

All Zulip settings can be configured through environment variables prefixed with SETTING_ (which map to entries in /etc/zulip/settings.py inside the container) or through the admin web UI at Organization settings.

Authentication Backends

Zulip supports multiple authentication methods. Configure them through environment variables in your .env file:

# Enable/disable email+password authentication
SETTING_AUTHENTICATION_BACKENDS=EmailAuthBackend

# For LDAP authentication
SETTING_AUTHENTICATION_BACKENDS=ZulipLDAPAuthBackend
SETTING_AUTH_LDAP_SERVER_URI=ldap://ldap.example.com
SETTING_AUTH_LDAP_BIND_DN=cn=admin,dc=example,dc=com
SECRETS_auth_ldap_bind_password=ldap-bind-password

# For SAML/OIDC (configure in admin UI under Authentication methods)
# GitHub, GitLab, Google, and Apple sign-in are also supported

Multiple backends can be enabled simultaneously by setting a Python list:

SETTING_AUTHENTICATION_BACKENDS=EmailAuthBackend,GitHubAuthBackend

Email and SMTP

Email is critical for Zulip — it handles account verification, password resets, notifications, and invitations. The .env file above includes the SMTP settings. Test email delivery from the admin shell:

docker compose exec zulip su zulip -c '/home/zulip/deployments/current/manage.py send_test_email [email protected]'

If you are running your own mail server (Mailu, Mailcow), point the SMTP settings at it. For transactional email services, Zulip works with any standard SMTP provider.

Custom Branding

Customize your instance from the admin UI at Organization settings → Organization profile:

  • Organization name, description, and icon
  • Custom login page text
  • Default language
  • Custom emoji

For deeper customization, mount a custom CSS file:

volumes:
  - ./custom.css:/home/zulip/deployments/current/static/styles/custom.css:ro

Organizations and Permissions

Zulip has a granular permissions system:

  • Organization Owner — full control, can deactivate the organization
  • Organization Administrator — manages settings, users, streams
  • Moderator — manages content and streams
  • Member — standard user
  • Guest — restricted access to specific streams only

Configure default new-user roles, stream creation permissions, and invite policies under Organization settings → Organization permissions.

Bots and Integrations

Zulip has 100+ built-in integrations (GitHub, GitLab, JIRA, Sentry, PagerDuty, Jenkins, and more). Configure them at Settings → Bots & integrations:

  1. Create an Incoming Webhook Bot for each service
  2. Copy the webhook URL and configure it in the external service
  3. Messages appear in the stream and topic you configure

For custom integrations, Zulip’s REST API is comprehensive and well-documented. Create API keys at Settings → Personal settings → API key.

SSL and Certificates

Zulip’s Docker image handles SSL termination internally via Nginx. You have three options:

Set the environment variable to use Certbot:

SSL_CERTIFICATE_GENERATION=certbot

The container will automatically obtain and renew certificates from Let’s Encrypt. Ports 80 and 443 must be reachable from the internet.

Option 2: Self-Signed (Development/Testing)

The default configuration generates a self-signed certificate:

SSL_CERTIFICATE_GENERATION=self-signed

Browsers will show a security warning. Acceptable for internal testing, not for production.

Option 3: Custom Certificate

Mount your own certificate and key:

volumes:
  - /path/to/your/cert.pem:/data/certs/zulip.combined-chain.crt:ro
  - /path/to/your/key.pem:/data/certs/zulip.key:ro
environment:
  SSL_CERTIFICATE_GENERATION: "manual"

Option 4: Reverse Proxy with HTTPS Termination

If a reverse proxy handles SSL, disable HTTPS in Zulip:

DISABLE_HTTPS=true

Then expose only port 80 and let the proxy handle TLS. See the Reverse Proxy section below.

Reverse Proxy

If you run Zulip behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy), you need to tell Zulip to trust the proxy’s forwarded headers. Otherwise, Zulip sees all traffic as coming from the proxy IP.

Add to your .env file:

# Option A: Specify the proxy IP(s) explicitly
LOADBALANCER_IPS=172.17.0.1

# Option B: Trust all gateway IPs (simpler but less secure)
# SETTING_USE_X_FORWARDED_HOST=True

If you use DISABLE_HTTPS=true, update your compose.yaml to expose only port 80:

ports:
  - "8080:80"    # Map to an unprivileged port for the proxy to reach

Nginx Proxy Manager configuration:

  • Scheme: http
  • Forward Hostname: your-server-ip (or container name if on the same Docker network)
  • Forward Port: 8080 (or whatever you mapped)
  • Enable WebSocket Support: Yes (required for real-time message updates)

Caddy reverse proxy example:

zulip.example.com {
    reverse_proxy localhost:8080
}

For Traefik, ensure WebSocket support is enabled (it is by default) and add appropriate labels to the Zulip service.

See Reverse Proxy Setup for full configuration guides.

Backup

Zulip stores data across multiple volumes. Back up all of them:

# Stop services for a consistent backup
docker compose stop

# Back up PostgreSQL data
docker run --rm \
  -v zulip_postgresql:/data \
  -v $(pwd)/backups:/backup \
  alpine tar czf /backup/zulip-postgres-$(date +%Y%m%d).tar.gz -C / data

# Back up Zulip application data (uploads, avatars, config)
docker run --rm \
  -v zulip_data:/data \
  -v $(pwd)/backups:/backup \
  alpine tar czf /backup/zulip-data-$(date +%Y%m%d).tar.gz -C / data

# Back up RabbitMQ data
docker run --rm \
  -v zulip_rabbitmq:/data \
  -v $(pwd)/backups:/backup \
  alpine tar czf /backup/zulip-rabbitmq-$(date +%Y%m%d).tar.gz -C / data

# Restart services
docker compose start

For a hot backup of the database (no downtime):

docker compose exec database pg_dump -U zulip zulip > backups/zulip-db-$(date +%Y%m%d).sql

Critical data to back up:

  • PostgreSQL database — all messages, users, streams, organization settings
  • Zulip /data volume — uploaded files, avatars, custom emoji, server configuration
  • .env file — your secrets and settings (store this securely outside the server too)
  • RabbitMQ data — message queues (less critical, will rebuild, but preserving avoids reprocessing)

See Backup Strategy for a comprehensive approach.

Troubleshooting

Services Fail to Start — Out of Memory

Symptom: Containers restart repeatedly. docker compose logs zulip shows Killed or the OOM killer messages appear in dmesg. Fix: Zulip’s five-service stack needs at least 2 GB of RAM plus 2 GB of swap. Check available memory with free -h. If your server has less than 2 GB free, either add RAM or configure swap (see Prerequisites). You can also reduce memory pressure by adding to your Zulip environment:

SETTING_CACHES_TIMEOUT=3600

Monitor memory usage per container with docker stats.

Email Delivery Failures

Symptom: Users do not receive invitation emails, password reset links, or notifications. The Zulip admin panel shows email errors. Fix: Test SMTP settings from inside the container:

docker compose exec zulip su zulip -c '/home/zulip/deployments/current/manage.py send_test_email [email protected]'

Common causes: wrong SMTP port (use 587 for STARTTLS, 465 for SSL), incorrect credentials, or your mail provider blocks the VPS IP. Check docker compose logs zulip for specific SMTP errors. If your hosting provider blocks port 25/587 outbound, use a transactional email service like Resend, Mailgun, or Amazon SES.

WebSocket Errors Behind a Reverse Proxy

Symptom: The web UI loads but messages do not update in real-time. Browser console shows WebSocket connection failures or Tornado errors. Fix: Zulip uses Tornado for real-time events over long-polling and WebSockets. Your reverse proxy must support WebSocket connections. In Nginx, add:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;

In Nginx Proxy Manager, enable WebSocket Support on the proxy host. Also verify that LOADBALANCER_IPS is set correctly so Zulip trusts the forwarded headers — without this, connections may be rejected.

Database Migration Errors on Upgrade

Symptom: After pulling a new Zulip image, the container fails to start with migration errors or database schema mismatches. Fix: Zulip runs migrations automatically on startup. If a migration fails:

  1. Check the logs: docker compose logs zulip
  2. If the error mentions a lock or concurrent migration, ensure only one Zulip container is running
  3. Try running migrations manually:
docker compose exec zulip su zulip -c '/home/zulip/deployments/current/manage.py migrate'
  1. If upgrading across multiple major versions, upgrade one version at a time. Zulip does not support skipping major versions. Check the upgrade notes for version-specific instructions.

Always back up the database before upgrading.

High CPU and Memory Usage

Symptom: The server becomes slow. docker stats shows Zulip consuming excessive resources even with few users. Fix: Zulip runs multiple internal processes (Django, Tornado, workers). On a small server with under 100 users, you can reduce worker count:

SETTING_PRODUCTION_WORKERS=2

The default is based on CPU count, which can be aggressive on small VPS instances. Also check if full-text search indexing is running — this is CPU-intensive on first run but settles down. PostgreSQL may also benefit from tuning if memory is tight. Ensure shared_buffers and work_mem are appropriate for your server size.

RabbitMQ Connection Refused

Symptom: Zulip logs show ConnectionRefusedError for RabbitMQ on port 5672. Fix: RabbitMQ can be slow to start, especially on first run. Wait 30-60 seconds and check docker compose logs rabbitmq. If RabbitMQ keeps restarting, it may be running out of disk space (RabbitMQ refuses to start when free disk is below a threshold). Check disk space with df -h. If the RabbitMQ password was changed after initial setup, you need to delete the RabbitMQ volume and recreate it:

docker compose down
docker volume rm zulip_rabbitmq
docker compose up -d

Resource Requirements

  • RAM: 2 GB minimum (with 2 GB swap), 4 GB recommended for up to 100 active users, 8 GB+ for 100-500 users
  • CPU: 2 cores minimum, 4 cores recommended for 50+ concurrent users
  • Disk: ~2 GB for the application and dependencies, plus storage for uploaded files and database growth. Plan 1-5 GB per year per active user depending on file sharing volume.
  • Swap: 2 GB strongly recommended regardless of RAM — Zulip’s multiple processes can spike during migrations and indexing

The five-service architecture (Zulip, PostgreSQL, Memcached, RabbitMQ, Redis) makes Zulip one of the heavier self-hosted chat options. Budget accordingly.

Verdict

Zulip’s topic-based threading model is genuinely better than Slack-style chronological chat for asynchronous teams. If your team spans time zones or people need to catch up on discussions hours later, Zulip’s structure means you can read just the topics that matter instead of scrolling through an entire channel hoping you did not miss something. This is not a marginal improvement — it fundamentally changes how team communication scales.

The trade-off is complexity. Five services, 2 GB RAM minimum, and a non-trivial configuration make Zulip the heaviest chat platform to self-host. But if you actually use the threading model, the payoff justifies the setup effort.

If you want simpler deployment and a Slack-like experience is fine, Mattermost is the easier choice — two services, lower resource requirements, and a familiar interface. If you need federation across organizations, Matrix/Element is the better path. But for a single team that values organized, async-friendly discussion, Zulip has no real competitor.