Jump to content

Zulip Portainer Configuration 260319

From Game in the Brain Wiki

Zulip — Docker Setup on Portainer CE

Self-hosted team chat server running on Docker via Portainer CE, proxied through Nginx Proxy Manager. This documents the corrected deployment process including all encountered issues and their fixes.

  • Domain: chat.gi7b.org
  • Host: PC02dellmicro (ZeroTier IP: 192.168.196.189)
  • Stack name: zulip260319
  • Image: zulip/docker-zulip:9.4-0

Prerequisites

  • Portainer CE running with access to Docker host
  • Nginx Proxy Manager already deployed
  • Domain pointed to your server (DNS A record for chat.gi7b.org)
  • ZeroTier connected between NPM host and Zulip host (or both on same machine)
  • Gmail account with 2-Step Verification enabled (for App Password SMTP)

Docker Compose Stack

Paste this into Portainer → Stacks → Add Stack → Web editor. Replace all placeholder values before deploying.

version: "2"
services:
  database:
    image: zulip/zulip-postgresql:14
    environment:
      POSTGRES_DB: "zulip"
      POSTGRES_USER: "zulip"
      POSTGRES_PASSWORD: "your-db-password"
    volumes:
      - postgresql-14:/var/lib/postgresql/data:rw

  memcached:
    image: memcached:alpine
    command: ["-m", "128"]

  rabbitmq:
    image: rabbitmq:3.11.16
    environment:
      RABBITMQ_DEFAULT_USER: "zulip"
      RABBITMQ_DEFAULT_PASS: "your-rabbitmq-password"
    volumes:
      - rabbitmq:/var/lib/rabbitmq:rw

  redis:
    image: redis:alpine
    command: redis-server --requirepass "your-redis-password"
    volumes:
      - redis:/data:rw

  zulip:
    image: zulip/docker-zulip:9.4-0
    ports:
      - "192.168.196.189:8010:80"
    environment:
      DB_HOST: "database"
      DB_HOST_PORT: "5432"
      DB_USER: "zulip"
      SETTING_MEMCACHED_LOCATION: "memcached:11211"
      SETTING_RABBITMQ_HOST: "rabbitmq"
      SETTING_REDIS_HOST: "redis"
      SECRETS_rabbitmq_password: "your-rabbitmq-password"
      SECRETS_postgres_password: "your-db-password"
      SECRETS_redis_password: "your-redis-password"
      SECRETS_secret_key: "generate-64-char-hex-string"
      SECRETS_email_password: "your-gmail-app-password"
      SETTING_EXTERNAL_HOST: "chat.gi7b.org"
      SETTING_ZULIP_ADMINISTRATOR: "your@email.com"
      SETTING_EMAIL_HOST: "smtp.gmail.com"
      SETTING_EMAIL_HOST_USER: "your@gmail.com"
      SETTING_EMAIL_PORT: "587"
      SETTING_EMAIL_USE_TLS: "True"
      SETTING_PUSH_NOTIFICATION_BOUNCER_URL: "https://push.zulipchat.com"
      SETTING_USE_X_FORWARDED_HOST: "True"
      SSL_CERTIFICATE_GENERATION: "self-signed"
      LOADBALANCER_IPS: "0.0.0.0/0"
    volumes:
      - zulip:/data:rw
    ulimits:
      nofile:
        soft: 1000000
        hard: 1048576
    depends_on:
      - database
      - memcached
      - rabbitmq
      - redis

volumes:
  postgresql-14:
  rabbitmq:
  redis:
  zulip:

Secret Key Generation

Generate a 64-character hex string for SECRETS_secret_key:

openssl rand -hex 32

Gmail App Password

SECRETS_email_password must be a Gmail App Password, not your regular Gmail password.

  1. Go to myaccount.google.com → Security
  2. Ensure 2-Step Verification is ON
  3. Search for "App passwords"
  4. Select app: Mail, device: Other (label it "Zulip")
  5. Copy the 16-character password — enter it without spaces

Port Binding

The port is bound to the ZeroTier interface IP (192.168.196.189:8010) rather than 127.0.0.1 or 0.0.0.0. This makes it reachable from the NPM host over ZeroTier while keeping it off public/LAN interfaces.

Nginx Proxy Manager Configuration

Details Tab

Field Value
Domain Names chat.gi7b.org
Scheme http
Forward Hostname / IP 192.168.196.189
Forward Port 8010
Cache Assets On
Block Common Exploits On
Websockets Support On (required — Zulip breaks without this)

SSL Tab

Request a Let's Encrypt certificate for chat.gi7b.org.

Advanced Tab (gear icon ⚙️)

Paste this into the Custom Nginx Configuration box:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;

The X-Forwarded-Proto https line is critical — it tells Zulip the client is already on HTTPS, preventing redirect loops.

Fix: Infinite Redirect Loop

Symptom: ERR_TOO_MANY_REDIRECTS when accessing the site.

Cause: Zulip's internal nginx has a hardcoded HTTP→HTTPS redirect in /etc/nginx/sites-enabled/zulip-enterprise. Since NPM sends HTTP to port 8010, Zulip redirects back to HTTPS, which NPM sends as HTTP again — infinite loop.

Fix: Override the nginx config inside the running container:

docker exec -it zulip260319-zulip-1 bash

Then inside the container:

cat > /etc/nginx/sites-enabled/zulip-enterprise << 'EOF'
include /etc/nginx/zulip-include/trusted-proto;
include /etc/nginx/zulip-include/s3-cache;
include /etc/nginx/zulip-include/upstreams;
include /etc/zulip/nginx_sharding_map.conf;

server {
    listen 80;
    listen [::]:80;

    location /local-static {
        alias /home/zulip/local-static;
    }

    include /etc/nginx/zulip-include/certbot;
    include /etc/nginx/zulip-include/app;
}
EOF

nginx -s reload

Verify the redirect is gone:

curl -I http://localhost
# Should return HTTP/1.1 200 or 404 (not 301)

Note: This change does not survive a container restart. If the container is ever recreated, re-apply this fix. See #Persistent Fix below for a permanent solution.

Persistent Fix

To avoid re-applying after restarts, mount a custom nginx config as a bind mount. On the host:

mkdir -p /opt/stacks/zulip
cat > /opt/stacks/zulip/zulip-enterprise << 'EOF'
include /etc/nginx/zulip-include/trusted-proto;
include /etc/nginx/zulip-include/s3-cache;
include /etc/nginx/zulip-include/upstreams;
include /etc/zulip/nginx_sharding_map.conf;

server {
    listen 80;
    listen [::]:80;

    location /local-static {
        alias /home/zulip/local-static;
    }

    include /etc/nginx/zulip-include/certbot;
    include /etc/nginx/zulip-include/app;
}
EOF

Add to the zulip service volumes in the compose stack:

volumes:
  - zulip:/data:rw
  - /opt/stacks/zulip/zulip-enterprise:/etc/nginx/sites-enabled/zulip-enterprise:ro

Initial Setup: Create Organization

After all containers are running (verify with docker ps | grep zulip), generate the one-time realm creation link:

docker exec -it zulip260319-zulip-1 \
  su zulip -c '/home/zulip/deployments/current/manage.py generate_realm_creation_link'

This outputs a URL like:

https://chat.gi7b.org/new/xxxxxxxxxxxxxxxx

Open it in a browser to create your organization and first admin account. The link is single-use.

Troubleshooting

Container exits immediately on first boot

Check logs:

docker logs zulip260319-zulip-1 --tail 80

Common causes:

  • Redis AuthenticationError — Redis container has no password set but Zulip is sending one (or vice versa). Ensure SECRETS_redis_password in Zulip matches the --requirepass value in the Redis command.
  • Postgres password mismatchSECRETS_postgres_password must match POSTGRES_PASSWORD on the database service exactly.

502 Bad Gateway

  1. Check the container is actually running: docker ps | grep zulip
  2. Check port is bound: ss -tlnp | grep 8010
  3. Check ZeroTier interface is up: ip addr show | grep 192.168.196.189
  4. Test from host: curl -v http://192.168.196.189:8010

Build error in Portainer

Error: failed to read dockerfile: open /var/lib/docker/tmp/buildkit-mount.../Dockerfile: no such file or directory

Cause: The compose file contains a build: block which Portainer cannot resolve (no local source context).

Fix: Remove the entire build: block from the zulip service. The image: line is sufficient — Portainer pulls from Docker Hub directly.

Backup

The zulip named volume contains all data (uploads, avatars, settings). Back it up with:

docker run --rm \
  -v zulip260319_zulip:/data \
  -v /your/backup/path:/backup \
  alpine tar czf /backup/zulip-$(date +%Y%m%d).tar.gz -C /data .

Post-Setup: Mobile Push Notifications

Register with Zulip's free mobile push notification service:

docker exec -it zulip260319-zulip-1 \
  su zulip -c '/home/zulip/deployments/current/manage.py \
  register_server --agreed-to-terms-of-service'

Upgrades

Update the image tag in the Portainer stack (e.g. 9.4-09.5-0) and redeploy. Zulip runs database migrations automatically on boot. Re-apply the nginx redirect fix after upgrade if not using the persistent bind mount.