Skip to main content

Open-source alternatives guide

How to Self-Host Zulip Team Chat 2026

Self-host Zulip in 2026. Apache 2.0, ~22K stars, Python — topic-threaded team chat Slack alternative. Stream + topic model eliminates notification chaos.

·OSSAlt Team
Share:

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

Why Self-Host Zulip

Slack's pricing hits hard at scale. The Pro plan costs $7.25 per user per month. For a team of 25, that's $181.25/month — $2,175/year — and you lose message history older than 90 days on the free plan. Zulip's cloud offering starts at $6.67/user/month, but self-hosted Zulip is entirely free. For a 25-person team, self-hosting saves $1,800-2,000+ annually.

But cost is secondary to what makes Zulip genuinely worth considering: the stream + topic model is a fundamentally better architecture for async teams. Slack's flat channel model made sense when everyone was in the same timezone and online simultaneously. For distributed teams across multiple time zones, it breaks down — you come back from a day off to 200 messages in #engineering with no context about which conversations need your attention and which are already resolved.

Zulip solves this with topics. When you return after being away, you see a list of topics with unread counts. "Deploy: v2.4.1 to production (3 unread)" tells you exactly what to read. You can catch up on the deploy discussion, mark it read, and move on — without reading 50 unrelated messages. Topics can be marked "resolved" when done, keeping the stream clean. This isn't a marginal improvement; teams that switch from Slack to Zulip consistently report dramatically better async communication.

Data ownership and privacy are significant for team chat. Slack has access to every message your team sends. For companies discussing strategy, customer issues, security vulnerabilities, or confidential business information, that's a material risk. Self-hosted Zulip means your conversations stay on your infrastructure.

When NOT to self-host Zulip: Zulip's stack is heavier than Mattermost or Rocket.Chat — it requires PostgreSQL, Redis, RabbitMQ, and Memcached. On a small VPS, this is manageable but not trivial. Also, Zulip's topic model is genuinely polarizing — some teams find it confusing at first and never adapt. Run a trial on Zulip.com (free for open source projects) before committing to the self-hosted setup.

Prerequisites

Zulip is the most infrastructure-intensive of the common self-hosted team chat options. Its Python/Django stack requires multiple backing services running simultaneously.

Server specs: Minimum 4GB RAM for a functional setup — Zulip itself, PostgreSQL, Redis, RabbitMQ, and Memcached all running together consume 2-3GB at steady state. For teams up to 20 users with moderate activity, a 4 vCPU / 4GB RAM VPS is comfortable. Heavier usage or more users scales to 8GB RAM. Zulip's official documentation recommends 4GB as a minimum. Check our VPS comparison for self-hosters for current pricing at the 4GB tier — Hetzner's CX21 and Contabo's 4GB VPS are popular choices.

Email configuration: Zulip requires a working SMTP configuration before users can register and receive notifications. Test your SMTP settings before launching. If you don't have an SMTP server, services like Mailgun, Postmark, or AWS SES provide reliable transactional email. This is not optional — Zulip sends email confirmations, password resets, and notification digests.

Domain and DNS: A dedicated subdomain (e.g., chat.yourdomain.com) with proper DNS. Zulip's HTTPS setup and email links depend on SETTING_EXTERNAL_HOST matching your actual domain.

Operating system: Ubuntu 22.04 LTS. Zulip's Docker images are tested against Ubuntu, and the management scripts (manage.py commands) assume a Linux environment.

Skill level: Intermediate to advanced. Zulip has more moving parts than most self-hosted apps. Expect to spend 30-60 minutes on the initial setup, including SMTP configuration and organization creation.

Production Security Hardening

Zulip stores your organization's entire communication history. A breach would expose not just current messages but potentially years of conversations. Apply robust security from day one. Follow the self-hosting security checklist and these Zulip-specific steps:

Firewall (UFW): Zulip handles its own SSL when not behind a reverse proxy, but you should still use UFW to restrict unnecessary exposure.

sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Secrets management: Zulip uses multiple secrets — PostgreSQL password, RabbitMQ password, SMTP credentials. Never put these directly in docker-compose.yml. Use a .env file:

# .env (never commit this)
POSTGRES_PASSWORD=your-strong-db-password
RABBITMQ_PASS=your-rabbitmq-password
SMTP_PASS=your-smtp-password
echo ".env" >> .gitignore

Restrict registration: After creating accounts for your team, disable open registration in Zulip Admin → Organization Settings → Registration → set to invite-only. This prevents unauthorized users from creating accounts on your instance.

Disable SSH password authentication: Edit /etc/ssh/sshd_config: set PasswordAuthentication no and PermitRootLogin no. Use SSH keys exclusively. Restart: sudo systemctl restart ssh.

Automatic security updates:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Regular backups: Zulip's backup command (manage.py export) creates a full export including all messages, files, and user data. Run this on a schedule and upload to object storage. See automated server backups with restic for automated off-site backups. For Zulip specifically, also back up the PostgreSQL database directly with pg_dump as a secondary backup.

Adopting Zulip Successfully

The most common reason Zulip deployments fail isn't technical — it's organizational. Teams switch from Slack to Zulip, hit the learning curve of the stream/topic model, and revert within a week. The key is structured adoption rather than a cold switch.

Start with a pilot group of 5-10 people who are motivated to make it work. These should be people who genuinely feel the pain of Slack's chaotic notification stream — often remote workers, people across time zones, or team leads who need to track multiple parallel discussions. Run the pilot for 2-3 weeks and let them develop organic topic naming conventions before rolling out to the full team.

Topic naming conventions matter more than they seem. Zulip becomes significantly more useful when everyone uses consistent topic formats. Establish conventions before launch: use imperative phrases for actionable items ("Fix login bug on Safari"), use "RFC:" prefix for proposals that need discussion, use "Deploy: v2.x.x" for deployment notifications from bots. Document these conventions in a pinned message in the general stream.

The "narrow to topic" feature is Zulip's killer move for async communication. Teach new users immediately: when you navigate to a specific topic, you see only that conversation in full chronological order. You can read a week of back-and-forth on "Q4 roadmap planning" without any unrelated messages interrupting the thread. This is the behavior that makes Zulip genuinely better for async work — but users who don't know this feature exists will interact with Zulip like a worse Slack.

Integration placement matters for bot-generated messages. When you connect GitHub to Zulip, each repository's events should go to dedicated topics rather than flooding a general stream. "engineering" stream with topic "GitHub: myrepo/main" is clean. "engineering" stream with topic "GitHub" and all repos in the same topic is noise. Take time to configure your integration topic patterns before going live.

Zulip's mobile app is solid but differs enough from the desktop experience that you should brief new users on it. The key difference: on mobile, the "Home" view shows you streams with unread counts, and tapping a stream shows you topics with unread counts. The read count tracking across devices works reliably — read something on desktop and it's marked read on mobile.

Troubleshooting Common Issues

First startup hangs or Zulip container keeps restarting

Check docker compose logs -f zulip. The most common cause is the PostgreSQL container not being ready when Zulip tries to connect. Zulip's Docker image has retry logic, but sometimes a fresh start helps: docker compose down && docker compose up -d. Also verify all required environment variables are set — missing POSTGRES_PASSWORD or RABBITMQ_PASS causes silent failures.

Can't create organization — management command fails

If generate_realm_creation_link fails, Zulip may still be initializing. Wait 2-3 minutes after first startup for all services to stabilize, then retry. Check that the Zulip container is healthy: docker compose ps. If it shows as "restarting," view full logs to identify the root cause before proceeding.

Email notifications not sending

Test SMTP from within the Zulip container: docker exec -it zulip /home/zulip/deployments/current/manage.py send_test_email --to your-email. If this fails, your SMTP settings are wrong. Common issues: wrong SMTP host, port, or credentials; SMTP provider requires a specific "from" address that matches your verified domain.

Slow performance — pages take a long time to load

Zulip's performance on a RAM-constrained server degrades significantly. Check memory usage: free -h on the host. If available memory is below 1GB, processes are being swapped to disk. Increase RAM or add swap space. Also check PostgreSQL slow query logging if the database is the bottleneck: docker compose logs -f database | grep "duration:".

Users can't log in after update

Zulip updates sometimes include database migrations that require the container to run initialization scripts. If login fails after an update, check docker compose logs zulip | grep -i "migrate\|error". Most migration issues resolve by letting the container fully start and waiting for migration completion (which can take a few minutes on large databases).

High disk usage from uploaded files

Zulip stores file uploads in the zulip_data volume. For organizations that share many files, this can grow quickly. Configure S3 storage for uploads (Zulip supports AWS S3 and S3-compatible services) to offload file storage from your VPS disk.

See all open source team communication tools at OSSAlt.com/categories/communication.

See open source alternatives to Slack on OSSAlt.

The SaaS-to-Self-Hosted Migration Guide (Free PDF)

Step-by-step: infrastructure setup, data migration, backups, and security for 15+ common SaaS replacements. Used by 300+ developers.

Join 300+ self-hosters. Unsubscribe in one click.