Zulip Portainer Configuration 260319
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.
- Go to myaccount.google.com → Security
- Ensure 2-Step Verification is ON
- Search for "App passwords"
- Select app: Mail, device: Other (label it "Zulip")
- 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_passwordin Zulip matches the--requirepassvalue in the Redis command. - Postgres password mismatch —
SECRETS_postgres_passwordmust matchPOSTGRES_PASSWORDon the database service exactly.
502 Bad Gateway
- Check the container is actually running:
docker ps | grep zulip - Check port is bound:
ss -tlnp | grep 8010 - Check ZeroTier interface is up:
ip addr show | grep 192.168.196.189 - 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-0 → 9.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.