How to Self-Host Zulip: Organized Team Chat with Topic Threads 2026
TL;DR
Zulip (Apache 2.0, ~22K GitHub stars, Python/TypeScript) takes a fundamentally different approach to team chat. Instead of Slack's flat channels + chaotic notification stream, Zulip uses Streams (channels) with mandatory Topics (like email subjects). Every conversation has a topic, making it easy to follow multiple simultaneous discussions without losing context. Used by thousands of open source projects (Python Software Foundation, Rust, Julia) and companies who need organized async communication.
Key Takeaways
- Zulip: Apache 2.0, ~22K stars — stream + topic model, the alternative to Slack's chaos
- Topic threads: Every message belongs to a stream + topic — no more scrolling back to find context
- Async-first: Designed for teams across time zones — catch up on topics you missed
- 100+ integrations: GitHub, GitLab, Jira, PagerDuty, Grafana — all post to specific topics
- Keyboard-first: Powerful keyboard shortcuts — navigate messages without touching the mouse
- vs Slack/Mattermost: Zulip's threading model is unique — either you love it or find it different
Zulip's Stream + Topic Model
Understanding Zulip's core concept:
Slack/Mattermost model:
#engineering → flat stream of messages, hard to follow parallel convos
Zulip model:
Stream: engineering
Topic: "Deploy script broken" ← conversation thread
Topic: "Q4 roadmap planning" ← separate conversation
Topic: "Code review: PR #234" ← another thread
You can read a specific topic in full context, reply to old topics without spamming everyone, and "resolve" topics when done. This makes async work dramatically cleaner.
Part 1: Docker Setup
Zulip requires a slightly more complex setup:
# docker-compose.yml
services:
zulip:
image: zulip/docker-zulip:latest
container_name: zulip
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- zulip_data:/data
environment:
DB_HOST: "database"
DB_HOST_PORT: "5432"
DB_USER: "zulip"
SETTING_MEMCACHED_LOCATION: "memcached:11211"
SETTING_REDIS_HOST: "redis"
SETTING_RABBITMQ_HOST: "rabbitmq"
RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
SECRETS_email_password: "${SMTP_PASS}"
SETTING_EMAIL_HOST: "smtp.yourdomain.com"
SETTING_EMAIL_HOST_USER: "${SMTP_USER}"
SETTING_EMAIL_PORT: 587
SETTING_EMAIL_USE_TLS: "True"
SETTING_EXTERNAL_HOST: "chat.yourdomain.com"
SETTING_ZULIP_ADMINISTRATOR: "${ADMIN_EMAIL}"
SSL_CERTIFICATE_GENERATION: "self-signed" # Or use Caddy for TLS
# Disable if using Caddy:
# DISABLE_HTTPS: "true"
depends_on:
- database
- memcached
- redis
- rabbitmq
database:
image: zulip/zulip-postgresql:14
container_name: zulip_postgres
restart: unless-stopped
environment:
POSTGRES_DB: zulip
POSTGRES_USER: zulip
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- postgres_data:/var/lib/postgresql/data
memcached:
image: memcached:alpine
container_name: zulip_memcached
restart: unless-stopped
command: ["-l", "0.0.0.0"]
redis:
image: redis:alpine
container_name: zulip_redis
restart: unless-stopped
rabbitmq:
image: rabbitmq:3.11.11
container_name: zulip_rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: zulip
RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}"
volumes:
zulip_data:
postgres_data:
# .env
POSTGRES_PASSWORD=your-db-password
RABBITMQ_PASS=your-rabbitmq-password
SMTP_USER=you@yourdomain.com
SMTP_PASS=smtp-password
ADMIN_EMAIL=admin@yourdomain.com
docker compose up -d
Part 2: HTTPS with Caddy
Zulip handles its own SSL internally. To use Caddy as a reverse proxy instead:
environment:
DISABLE_HTTPS: "true"
SETTING_EXTERNAL_HOST: "chat.yourdomain.com"
Change port mapping:
ports:
- "8080:80"
chat.yourdomain.com {
reverse_proxy localhost:8080
}
Part 3: Initial Organization Setup
After first start:
# Create your organization:
docker exec -it zulip /home/zulip/deployments/current/manage.py generate_realm_creation_link
# Opens: https://chat.yourdomain.com/new/
# Or create directly:
docker exec -it zulip /home/zulip/deployments/current/manage.py create_realm \
--email admin@yourdomain.com \
--realm-subdomain "" \
--realm-name "My Team"
- Visit the link and create your organization
- Set the organization name
- Invite users via email or create an invite link
- Configure streams (channels)
Part 4: Streams and Topics Best Practices
Stream naming conventions
# Company-wide:
- general ← announcements, all-hands
- random ← off-topic
- watercooler ← social
# Team-specific:
- engineering ← technical discussions
- product ← product planning
- sales ← sales team
- support ← customer support
# Purpose-specific:
- deploys ← deployment notifications
- alerts ← monitoring alerts
- dev-frontend ← frontend team
- dev-backend ← backend team
Topic best practices
# Good topics:
"Deploy: v2.4.1 to production"
"Bug: Login fails on mobile Safari"
"RFC: New API rate limiting strategy"
"Onboarding: Alice joining backend team"
# Avoid:
"Question" ← too vague
"Help" ← not searchable later
"Issue" ← no context
Part 5: Bots and Integrations
GitHub integration
- Settings → Integrations → GitHub
- Select streams and events (push, PR, issues, reviews)
- Copy the webhook URL
- Add to GitHub: Repo → Settings → Webhooks
GitHub events post to topics automatically:
Stream: engineering
Topic: "GitHub: myrepo" → push events
Topic: "GitHub PR #42: Add dark mode" → PR events
Custom bot
import zulip
client = zulip.Client(
email="bot@yourdomain.com",
api_key="your-api-key",
site="https://chat.yourdomain.com"
)
# Send to a stream + topic:
client.send_message({
"type": "stream",
"to": "deploys",
"topic": "Production deploy v2.4.1",
"content": "✅ Deploy complete! All health checks passing."
})
# Send a DM:
client.send_message({
"type": "private",
"to": ["alice@yourdomain.com"],
"content": "Your PR was approved!"
})
# Subscribe to messages and reply:
def handle_message(msg):
if "hello" in msg["content"].lower():
client.send_message({
"type": "stream",
"to": msg["display_recipient"],
"topic": msg["subject"],
"content": f"Hi @**{msg['sender_full_name']}**!"
})
client.call_on_each_message(handle_message)
Part 6: Zulip CLI and REST API
# Install Zulip CLI:
pip install zulip
# Configure:
cat > ~/.zuliprc << EOF
[api]
email=you@yourdomain.com
key=your-api-key
site=https://chat.yourdomain.com
EOF
# Send a message:
zulip-send --stream engineering --subject "Deploy complete" \
--message "v2.4.1 deployed to production" --config-file ~/.zuliprc
# REST API:
API_KEY="your-api-key"
EMAIL="you@yourdomain.com"
# Get streams:
curl -u "${EMAIL}:${API_KEY}" \
https://chat.yourdomain.com/api/v1/streams | jq
# Post a message:
curl -u "${EMAIL}:${API_KEY}" \
https://chat.yourdomain.com/api/v1/messages \
-d "type=stream" \
-d "to=engineering" \
-d "topic=Test topic" \
-d "content=Hello from API"
Part 7: Keyboard Shortcuts
Zulip's keyboard-first design:
| Shortcut | Action |
|---|---|
n | Next unread topic |
j/k | Navigate messages |
r | Reply to message |
c | Compose new message |
q | Go to private messages |
g | Go to stream |
/ | Search messages |
Ctrl+. | Mark topic as read |
Ctrl+K | Jump to a stream |
@ | Mention a user |
# | Mention a stream |
* | Star a message |
m | Mute/unmute topic |
Maintenance
# Update Zulip:
docker compose pull
docker compose up -d
# Backup:
docker exec zulip /home/zulip/deployments/current/manage.py export --output /tmp/zulip-export
docker cp zulip:/tmp/zulip-export ./zulip-backup-$(date +%Y%m%d)
# Or backup database directly:
docker exec zulip-database-1 pg_dump -U zulip zulip \
| gzip > zulip-db-$(date +%Y%m%d).sql.gz
# Restart services:
docker compose restart zulip
# Logs:
docker compose logs -f zulip
See all open source team communication tools at OSSAlt.com/categories/communication.