Skip to Content

Production Guide: Deploy Zulip with Docker Compose + Caddy + PostgreSQL + Redis + RabbitMQ on Ubuntu

Deploy Zulip on Ubuntu with Docker Compose, Caddy reverse proxy, PostgreSQL, Redis, RabbitMQ, and Memcached for a production-ready team chat server.

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 ps shows 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_PASSWORD in the Zulip service matches POSTGRES_PASSWORD and that the postgres service is on the same Docker network.
  • Email not delivering: Confirm SMTP host, port, TLS setting, and credentials. Test with swaks or msmtp from 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_SIZE defaults 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 up and 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

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Header image: Original SysBrix generated header, no watermark.

Production Guide: Deploy ToolJet with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
A practical production guide for running ToolJet with operational validation and recovery patterns.