Group chat is where decisions disappear into scrollback and important context gets buried under memes. Zulip is an open-source team chat platform built around topic-based threading, which means every conversation has a subject line and future teammates can read a channel without losing hours to context archaeology. For engineering, operations, and product teams, Zulip offers a disciplined alternative to noisy channels: threaded discussions, powerful search, native apps, and deep integrations with issue trackers, CI systems, and monitoring tools.
In this guide, we will deploy Zulip on Ubuntu with Docker Compose, publish it through Caddy with automatic HTTPS, and wire in the full application stack: PostgreSQL for persistence, Redis for caching and real-time presence, RabbitMQ for queueing, and Memcached for additional caching. The target audience is a platform team, small business, or internal IT group that wants a maintainable, self-hosted chat server. The pattern keeps the application stack isolated, exposes only Caddy to the public internet, stores secrets in an environment file with restricted permissions, and verifies each layer before inviting users. You can integrate SAML, SCIM, and LDAP later, but this baseline gives you a dependable, upgrade-friendly foundation.
Architecture and flow overview
The browser talks to Caddy on ports 80 and 443. Caddy terminates TLS and reverse-proxies to the Zulip server container bound to 127.0.0.1 on port 80 inside the Docker network. Zulip itself is a Django application served by Tornado and uWSGI workers; it depends on PostgreSQL for the main database, Redis for real-time push and caching, RabbitMQ for background job queues, and Memcached for cached objects. Persistent data lives in Docker volumes: one for PostgreSQL, one for Redis, one for RabbitMQ, one for uploaded files and avatars, and one for Zulip settings. Logs are written to the container stdout by default and can be collected with your existing log shipping stack. The flow is intentionally simple: one public entry point, one application server, and clearly separated backing services.
Prerequisites
- Ubuntu 22.04 or 24.04 LTS server with at least 2 CPU cores, 4 GB RAM, and 40 GB disk.
- A DNS A record pointing your domain to the server public IP.
- Docker Engine 24.x and Docker Compose plugin installed.
- Caddy installed as a system package or binary.
- UFW or another firewall allowing SSH (22), HTTP (80), and HTTPS (443).
- An SMTP relay or mail provider account for outbound email (required for invitations and notifications).
Step-by-step deployment
1) Install Docker, Compose, Caddy, and firewall basics
sudo apt update && sudo apt install -y ca-certificates curl gnupg ufw
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \"deb [arch=\"$(dpkg --print-architecture)\" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \"$(. /etc/os-release && echo \"$VERSION_CODENAME\")\" stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
2) Create directories and environment file
sudo mkdir -p /opt/zulip/{data,postgres,redis,rabbitmq,memcached}
sudo chown -R "$USER":"$USER" /opt/zulip
chmod 750 /opt/zulip
Create /opt/zulip/.env with the following content. Replace secrets with strong random values and set your domain and email credentials.
ZULIP_DOMAIN=chat.example.com
[email protected]
POSTGRES_DB=zulip
POSTGRES_USER=zulip
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
RABBITMQ_DEFAULT_USER=zulip
RABBITMQ_DEFAULT_PASS=$(openssl rand -hex 32)
MEMCACHED_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY=$(openssl rand -hex 50)
EMAIL_HOST=smtp.mailprovider.com
EMAIL_PORT=587
[email protected]
EMAIL_HOST_PASSWORD=your-email-password-or-app-key
EMAIL_USE_TLS=true
EMAIL_FROM=Zulip <[email protected]>
Lock the file:
chmod 600 /opt/zulip/.env
3) Define Compose services
Create /opt/zulip/docker-compose.yml:
version: "3.8"
services:
postgres:
image: postgres:15-alpine
container_name: zulip_postgres
restart: unless-stopped
env_file: .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- zulip
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: zulip_redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- ./redis:/data
networks:
- zulip
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
rabbitmq:
image: rabbitmq:3.12-alpine
container_name: zulip_rabbitmq
restart: unless-stopped
env_file: .env
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
volumes:
- ./rabbitmq:/var/lib/rabbitmq
networks:
- zulip
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "status"]
interval: 15s
timeout: 5s
retries: 5
memcached:
image: memcached:1.6-alpine
container_name: zulip_memcached
restart: unless-stopped
command: memcached -S -m 256 -c 1024
networks:
- zulip
healthcheck:
test: ["CMD", "sh", "-c", "echo 'stats' | nc -w 1 localhost 11211"]
interval: 10s
timeout: 5s
retries: 5
zulip:
image: zulip/docker-zulip:9.0-0
container_name: zulip_server
restart: unless-stopped
env_file: .env
environment:
DB_HOST: postgres
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD}
RABBITMQ_HOST: rabbitmq
RABBITMQ_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_PASSWORD: ${RABBITMQ_DEFAULT_PASS}
MEMCACHED_LOCATION: memcached:11211
MEMCACHED_PASSWORD: ${MEMCACHED_PASSWORD}
SECRETS_secret_key: ${SECRET_KEY}
SETTING_EXTERNAL_HOST: ${ZULIP_DOMAIN}
SETTING_ZULIP_ADMINISTRATOR: ${ZULIP_ADMIN_EMAIL}
SETTING_EMAIL_HOST: ${EMAIL_HOST}
SETTING_EMAIL_PORT: ${EMAIL_PORT}
SETTING_EMAIL_HOST_USER: ${EMAIL_HOST_USER}
SETTING_EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
SETTING_EMAIL_USE_TLS: ${EMAIL_USE_TLS}
SETTING_DEFAULT_FROM_EMAIL: ${EMAIL_FROM}
SETTING_NOREPLY_EMAIL_ADDRESS: ${EMAIL_FROM}
volumes:
- ./data:/data
networks:
- zulip
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
memcached:
condition: service_healthy
ports:
- "127.0.0.1:80:80"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
zulip:
driver: bridge
4) Configure Caddy reverse proxy
Create /etc/caddy/Caddyfile (or add a site block):
chat.example.com {
reverse_proxy 127.0.0.1:80
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
Validate and reload Caddy:
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy
5) Start services and verify health
cd /opt/zulip
docker compose up -d
sleep 30
docker compose ps
docker compose logs --tail 50 zulip
Wait until the Zulip container reports that the server is listening. The first startup runs database migrations and static asset compilation, so it may take two to three minutes.
6) Run first-time setup
Create the initial organization and admin user:
docker exec -it zulip_server /bin/bash -c \"
/home/zulip/deployments/current/scripts/setup/initialize-database
su zulip -c '/home/zulip/deployments/current/manage.py create_realm \\n --string_id=\\\"\\\" \\n --name=\\\"Example Corp\\\" \\n --description=\\\"Main organization\\\"'
su zulip -c '/home/zulip/deployments/current/manage.py create_user \\n --email ${ZULIP_ADMIN_EMAIL} \\n --full-name \"Admin User\" \\n --realm-name \"Example Corp\"'
su zulip -c '/home/zulip/deployments/current/manage.py changepassword ${ZULIP_ADMIN_EMAIL}'
\"
After setting the password, open your domain in a browser, log in with the admin email, and finish the organization setup wizard.
7) Backup script
Create /opt/zulip/backup.sh:
#!/bin/bash
set -euo pipefail
BACKUP_DIR=/opt/zulip/backups/$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
docker exec zulip_postgres pg_dump -U zulip zulip | gzip > "$BACKUP_DIR/zulip.sql.gz"
tar czf "$BACKUP_DIR/data.tar.gz" -C /opt/zulip data
find /opt/zulip/backups -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
chmod +x /opt/zulip/backup.sh
/opt/zulip/backup.sh
Schedule it in cron:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/zulip/backup.sh") | crontab -
8) Acceptance checklist execution
- Caddy serves HTTPS with a valid certificate.
- Zulip login page loads without mixed-content warnings.
- Admin login succeeds and the organization settings page opens.
- Invitations can be sent and received (test email flow).
- Topic creation, message sending, and search return results.
- File uploads succeed and appear in messages.
docker compose psshows all services healthy.- Backup archive exists and can be decompressed.
Configuration and secrets handling
All sensitive values live in /opt/zulip/.env with mode 600. The file is never copied into images; it is mounted at runtime by Docker Compose. Rotate the SECRET_KEY only during a planned maintenance window because it encrypts session data and uploaded file metadata. For SMTP credentials, use an app-specific password or a dedicated relay user rather than a personal mailbox password. If you run Zulip behind a corporate proxy, export HTTP_PROXY and HTTPS_PROXY in the host environment before starting Compose, or add them to the Zulip service environment block.
Verification
Run these checks from the server:
curl -s -o /dev/null -w "%{http_code}" https://chat.example.com
# Expected: 200
docker compose exec postgres pg_isready -U zulip
# Expected: accepting connections
docker compose exec redis redis-cli --raw -a "$REDIS_PASSWORD" ping
# Expected: PONG
docker compose exec rabbitmq rabbitmq-diagnostics status >/dev/null && echo "RabbitMQ OK"
# Expected: RabbitMQ OK
Common issues and fixes
- Container exits on startup: Check
docker compose logs zulip. The most common cause is a missing or unhealthy backing service. Ensure PostgreSQL and RabbitMQ are healthy before Zulip starts. - Database connection errors: Verify that
DB_PASSWORDin the Zulip service matchesPOSTGRES_PASSWORDand that thepostgresservice is on the same Docker network. - Email not delivering: Confirm SMTP host, port, TLS setting, and credentials. Test with
swaksormsmtpfrom the host. Check Zulip logs for SMTP authentication failures. - File upload size limits: Caddy does not impose a client max body size by default, but Zulipβs
MAX_FILE_UPLOAD_SIZEdefaults to 25 MB. Increase it in the Zulip environment if needed. - 502 Bad Gateway: This usually means Zulip is still starting or crashed. Wait two minutes after
docker compose upand check health status. - Upgrade failures: Always back up before upgrading the Zulip image tag. Run
docker compose pull && docker compose up -d, then watch logs for migration output.
FAQ
Can I run Zulip without RabbitMQ?
No. Zulip uses RabbitMQ for queuing background jobs like email delivery, digest generation, and import/export tasks. Removing it will cause the server to fail startup checks.
How much disk space does Zulip need?
A minimal install with a few thousand messages uses roughly 5 GB. Plan for at least 40 GB to leave room for file uploads, database growth, and backup retention.
Can I use an external database instead of the Compose Postgres service?
Yes. Set DB_HOST to your external host, ensure the Zulip container can reach it on port 5432, and create the database and user in advance. Keep the same DB_NAME, DB_USER, and DB_PASSWORD values.
How do I enable mobile push notifications?
Zulipβs official mobile apps use a push notification service run by the Zulip team. Self-hosted servers can register for the push notifications bounty program or run their own push bouncer. Follow the Zulip documentation for PUSH_NOTIFICATION_BOUNCER_URL.
What is the best way to scale this setup?
For small to medium teams, a single Docker host with 4 GB RAM is sufficient. For larger deployments, move PostgreSQL and Redis to dedicated hosts, run multiple Zulip application containers behind a load balancer, and use an object store like MinIO or S3 for file uploads.
How do I restore from backup?
Stop the Zulip container, drop and recreate the database, restore the SQL dump, extract the data archive into /opt/zulip/data, and restart the stack. Test the restore on a staging host before relying on it in production.
Internal links
- Production Guide: Deploy NetBox with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy n8n with Docker Compose + Nginx + PostgreSQL on Ubuntu
- Production Guide: Deploy Plane with Docker Compose + Caddy + PostgreSQL + Redis + MinIO on Ubuntu
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.
Header image: Original SysBrix generated header, no watermark.