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
ulimitsettings 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
- Open
https://your-domainin your browser (accept the self-signed certificate warning if you have not configured a real certificate yet) - Click Register to create the first account using the email address you set as
SETTING_ZULIP_ADMINISTRATOR - This first account becomes the Organization Owner with full admin privileges
- Complete the organization setup wizard — name your organization, set default settings, and choose your authentication methods
- 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:
- Create an Incoming Webhook Bot for each service
- Copy the webhook URL and configure it in the external service
- 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:
Option 1: Let’s Encrypt (Recommended for Direct Exposure)
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
/datavolume — uploaded files, avatars, custom emoji, server configuration .envfile — 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:
- Check the logs:
docker compose logs zulip - If the error mentions a lock or concurrent migration, ensure only one Zulip container is running
- Try running migrations manually:
docker compose exec zulip su zulip -c '/home/zulip/deployments/current/manage.py migrate'
- 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.
Related
Get self-hosting tips in your inbox
New guides, comparisons, and setup tutorials — delivered weekly. No spam.