Skip to main content

How to Self-Host Zulip: Organized Team Chat with Topic Threads 2026

·OSSAlt Team
zulipslackteam-chatself-hostingdocker2026

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"
  1. Visit the link and create your organization
  2. Set the organization name
  3. Invite users via email or create an invite link
  4. 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

  1. Settings → Integrations → GitHub
  2. Select streams and events (push, PR, issues, reviews)
  3. Copy the webhook URL
  4. 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:

ShortcutAction
nNext unread topic
j/kNavigate messages
rReply to message
cCompose new message
qGo to private messages
gGo to stream
/Search messages
Ctrl+.Mark topic as read
Ctrl+KJump to a stream
@Mention a user
#Mention a stream
*Star a message
mMute/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.

Comments