What you will set up
Zulip is a chat for a company or a team, much like Slack, with one difference: every message in a channel belongs to a topic. A channel such as sales holds many short conversations, such as Q4 offers or Acme contract, each with its own thread, so people can catch up on one subject without reading everything. It also has direct messages, mentions, file sharing, search, user groups, and apps for Windows, macOS, Linux, Android and iOS as well as the web.
Zulip is made by Kandra Labs, Inc., a company in San Francisco, and developed in the open on GitHub. It is open source under the Apache 2.0 licence, and the self-hosted server has every feature, without a user limit. What you pay for, if anything, is the push notification service for phones (step 11) and support. Zulip Cloud, where Kandra Labs runs the server for you, is free with search limited to the last 10,000 messages, and costs from $6.67 per user and month for Standard, according to its price list on 14 October 2026.
Zulip's own container image, ghcr.io/zulip/zulip-server, runs everything Zulip needs except four services, each in a container of its own: PostgreSQL for the data, RabbitMQ for the queues of background work, Redis for rate limits and memcached as a cache. Here all five run as a pod under a user of its own called zulip, behind Caddy from the Podman guide. Zulip's documentation says that the image does not support rootless Docker. It ran without root in Podman in our tests, with the open-file limit set in step 3.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Zulip 12.3 started with PostgreSQL 14, RabbitMQ 4.2, Redis and memcached. The organization was created with a one-time link, and a sign-up without an invitation was refused with "You need an invitation to join this organization."
- Users joined by email invitation and through an invitation link, as member, moderator, administrator and guest. The guest saw only the one channel they were invited to, could not join others, and, after one setting, saw only the people in that channel.
- A message mentioning a user appeared in that user's open browser half a second after it was sent, through Caddy. Direct messages, a mention of a user group, search, and an edited message with its earlier version in the edit history all worked.
- A 90 MB file was uploaded and downloaded intact by another user, a visitor without a login was sent to the login page, and a guest outside the channel got "403". A 110 MB file was refused with "exceeds the maximum file size for attachments (100 MB)".
- Zulip logged each visitor's real address, over IPv4 and IPv6, and ignored a forged
X-Forwarded-Forheader. The "new login" email showed the real address too. - With email pointed at a test mail server, over STARTTLS with a login, invitations, sign-up confirmations, a direct message notification and new login warnings arrived.
- An incoming webhook posted a monitoring alert, and a bot posted a message through the API.
- The interface was shown in Swedish, for one user and as the default for new users. Only about a tenth of it is translated (step 9).
- A capture of the server's traffic showed no outside connections from Zulip while it started and was used.
- An update from Zulip 12.2 to 12.3 kept every message and file. A backup was restored onto a freshly set up Zulip with all users, messages and files, and everything came back by itself after a reboot.
The first start took about five minutes, most of it downloading 3.6 GB of images. The pod used about 2.3 GB of memory when idle and 2.5 GB in use, most of it Zulip's own processes. That is more than our other chat guides, and the reason this guide suggests a server with 8 GB: Zulip asks for at least 2 GB and sizes itself for the whole server, which step 3 takes down from about 4 GB.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running;
- an A record and an AAAA record for
chat.example.compointing at your server; - an email account that can send through your email provider's SMTP server, such as
chat@example.com. Zulip needs email to invite people (step 6).
The examples use chat.example.com for Zulip, chat@example.com for its email account, 203.0.113.10 for your server's IPv4 address, which ip -brief address show eth0 shows, and Example for your organization. Replace them throughout.
1. Create the user
As root:
useradd -m -s /bin/bash zulip
loginctl enable-linger zulip
machinectl shell zulip@
Everything up to step 4 runs as zulip.
2. Create the secrets
for s in db memcached rabbitmq redis; do
openssl rand -hex 24 | tr -d '\n' | podman secret create zulip-$s-password -
done
These are the passwords Zulip uses for PostgreSQL, memcached, RabbitMQ and Redis. Podman stores them, not the files below. Zulip makes its own keys at the first start, and keeps them in its data volume.
3. Define the pod
mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd
Create zulip.pod:
[Pod]
PodName=zulip
# Only Caddy, on this server, can reach Zulip: the port is not open to the internet.
PublishPort=127.0.0.1:8179:80
[Install]
WantedBy=default.target
Create zulip-db.container:
[Container]
ContainerName=zulip-db
Image=docker.io/zulip/zulip-postgresql:14
Pod=zulip.pod
Volume=zulip-db:/var/lib/postgresql/data
Environment=POSTGRES_USER=zulip POSTGRES_DB=zulip
Secret=zulip-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U zulip
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
Create zulip-memcached.container:
[Container]
ContainerName=zulip-memcached
Image=docker.io/library/memcached:alpine
Pod=zulip.pod
Secret=zulip-memcached-password
Environment=SASL_CONF_PATH=/home/memcache/memcached.conf MEMCACHED_SASL_PWDB=/home/memcache/sasl-db
Exec=sh -euc 'echo "mech_list: plain" > $$SASL_CONF_PATH; echo "zulip@localhost:$$(cat /run/secrets/zulip-memcached-password)" > $$MEMCACHED_SASL_PWDB; exec memcached -S'
AutoUpdate=registry
[Service]
Restart=always
Create zulip-rabbitmq.container:
[Container]
ContainerName=zulip-rabbitmq
Image=docker.io/library/rabbitmq:4.2
Pod=zulip.pod
Volume=zulip-rabbitmq:/var/lib/rabbitmq
Environment=RABBITMQ_DEFAULT_USER=zulip
Secret=zulip-rabbitmq-password,type=env,target=RABBITMQ_DEFAULT_PASS
AutoUpdate=registry
[Service]
Restart=always
Create zulip-redis.container:
[Container]
ContainerName=zulip-redis
Image=docker.io/library/redis:alpine
Pod=zulip.pod
Volume=zulip-redis:/data
Secret=zulip-redis-password
Exec=sh -euc 'exec docker-entrypoint.sh redis-server --requirepass "$$(cat /run/secrets/zulip-redis-password)"'
AutoUpdate=registry
[Service]
Restart=always
And zulip-app.container:
[Unit]
Requires=zulip-db.service zulip-memcached.service zulip-rabbitmq.service zulip-redis.service
After=zulip-db.service zulip-memcached.service zulip-rabbitmq.service zulip-redis.service
[Container]
ContainerName=zulip-app
Image=ghcr.io/zulip/zulip-server:12.3-0
Pod=zulip.pod
Volume=zulip-data:/data
Environment=SETTING_EXTERNAL_HOST=chat.example.com SETTING_ZULIP_ADMINISTRATOR=chat@example.com
Environment=LOADBALANCER_IPS=203.0.113.10
Environment=CONFIG_application_server__queue_workers_multiprocess=False
Environment=SETTING_ENABLE_GRAVATAR=False SETTING_NAVIGATION_TOUR_VIDEO_URL=None
Environment=AUTO_BACKUP_ENABLED=False
Secret=zulip-db-password,target=/run/secrets/zulip__postgres_password
Secret=zulip-memcached-password,target=/run/secrets/zulip__memcached_password
Secret=zulip-rabbitmq-password,target=/run/secrets/zulip__rabbitmq_password
Secret=zulip-redis-password,target=/run/secrets/zulip__redis_password
Ulimit=nofile=524288:524288
[Service]
Restart=always
TimeoutStartSec=300
What the settings do:
EXTERNAL_HOSTis the address Zulip puts in links, invitations and emails, withouthttps://.ZULIP_ADMINISTRATORis the address users see when Zulip tells them to contact the administrators, and where it sends reports of server errors, so use a mailbox someone reads. Using the same address that sends Zulip's email (step 6) keeps email providers happy.LOADBALANCER_IPSis the address Zulip trusts to tell it the visitor's real address and that the connection was HTTPS. Caddy's connections arrive in the pod from the server's own IPv4 address, as the Podman guide explains, so that is the one to name. Without it, Zulip refuses every page with "Internal server error".queue_workers_multiprocess=Falseruns Zulip's background workers in one process instead of one each. Zulip sees all the server's memory and would otherwise size itself as if it were alone on it: it used about 4 GB that way in our tests, and 2.3 GB with this line. Zulip makes the same choice by itself on servers with less than 4 GB.ENABLE_GRAVATAR=FalseandNAVIGATION_TOUR_VIDEO_URL=Nonestop two things that load from outside in users' browsers: profile pictures from Gravatar and a welcome video from Zulip's servers. Step 12 has the details.AUTO_BACKUP_ENABLED=Falsestops the daily database dumps the image would otherwise keep in the data volume forever. Step 13 makes the backups instead.- The
Secret=lines put the passwords where Zulip's image looks for them. The four other containers read the same secrets. In the memcached and Redis files,$$is how a unit file writes$. Ulimitraises the number of files Zulip may keep open, as each connected browser holds one. Zulip's own setup asks for more than a user without root can grant, and this is the most it can.- The four helper containers listen only inside the pod, where nothing else can reach them. They follow updates by themselves; Zulip itself is updated by changing its tag (step 14).
zulip/zulip-postgresqlis PostgreSQL 14 with the dictionaries Zulip's search uses, made by Zulip. - The messages, users and settings are in the database. The uploaded files and the keys Zulip made at the first start are in the
zulip-datavolume.
Start it:
systemctl --user daemon-reload
systemctl --user start zulip-pod
systemctl --user enable --now podman-auto-update.timer
The first start downloads about 3.6 GB of images, and Zulip then takes a few minutes to set itself up. Each later start of zulip-app takes about a minute and a half. It is ready when this prints {"result":"success","msg":""}:
curl -s http://127.0.0.1:8179/health
4. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
chat.example.com {
reverse_proxy 127.0.0.1:8179
}
Restart Caddy with systemctl --user restart caddy, and open https://chat.example.com. Zulip shows "No organization found": there is none yet. Caddy passes on the long-lived connections Zulip uses for live messages by itself, and has no size limit of its own for uploads.
Putting Zulip online before the organization exists is safe. Nobody can create one without a link from the next step: https://chat.example.com/new/ answers "Organization creation link required".
5. Create the organization
Go back to root with exit, and switch to machinectl shell zulip@. Make a one-time link:
podman exec -u zulip zulip-app /home/zulip/deployments/current/manage.py generate_realm_creation_link
Open the link it prints, https://chat.example.com/new/..., in your browser. Fill in Organization name, set Organization type to Business, and give your email address under Your email. Create organization takes you to your own account: your name, a password and how you heard of Zulip, then Sign up. You are now the organization's owner, and Zulip opens.
The link works once. If you leave the page halfway, make a new one.
6. Email
Zulip sends email for invitations, for the link that confirms a new account, for password resets, for warnings about new logins, and for notifications about messages people missed. Without email, Zulip says "Password reset sent!" and that invitations were sent, but nothing leaves the server, and nobody can join through an invitation link either. So set it up before you invite anyone.
As zulip, store the SMTP password of your email account as a secret:
printf %s 'the-smtp-password' | podman secret create zulip-mail-password -
Then add these lines to zulip-app.container, before Ulimit=, with your provider's server and your account:
Environment=SETTING_EMAIL_HOST=mail.example.com SETTING_EMAIL_PORT=587 SETTING_EMAIL_USE_TLS=True
Environment=SETTING_EMAIL_HOST_USER=chat@example.com SETTING_NOREPLY_EMAIL_ADDRESS=chat@example.com SETTING_ADD_TOKENS_TO_NOREPLY_ADDRESS=False
Secret=zulip-mail-password,target=/run/secrets/zulip__email_password
Port 587 with EMAIL_USE_TLS=True uses STARTTLS; for port 465, which uses TLS from the start, set the port to 465 and replace SETTING_EMAIL_USE_TLS=True with SETTING_EMAIL_USE_SSL=True. NOREPLY_EMAIL_ADDRESS with ADD_TOKENS_TO_NOREPLY_ADDRESS=False sends everything from your account's own address, which most providers require. Restart and send a test:
systemctl --user daemon-reload
systemctl --user restart zulip-app
podman exec -u zulip zulip-app /home/zulip/deployments/current/manage.py send_test_email you@example.com
It ends with "Successfully sent 2 emails" when it works, or with the server's answer when it does not. Zulip checks the provider's certificate. We tested these settings against a test mail server on the same server, not through a real email provider.
7. Invite your team
Sign-ups need an invitation by default: https://chat.example.com/register/ says "You need an invitation to join this organization." To invite people, open the gear menu at the top right and choose Invite users:
- Email invitation sends a link to each address you enter. Choose the role under Users join as, and the channels under Channels they should join.
- Invitation link makes one link that anyone can use to sign up, with the role and channels you choose. Each person confirms their address by email, and Zulip sends at most five such emails a day for sign-ups from one IP address. For a whole office, use email invitations.
Each user has a role:
- Owner can do everything, including making other owners. You are the first.
- Administrator manages the organization, its users and its settings.
- Moderator is a member with extra rights that you can set, such as moving other people's messages to another topic.
- Member is the usual role.
- Guest sees only the channels they are added to, and cannot join or create others. Use it for customers and partners.
To invite a guest to one channel, choose Guests under Users join as, untick Default channels for this organization, and pick the channel. A guest still sees the names of everyone in the organization in the user list. To show them only the people in their channels, open Organization settings in the gear menu, and under Organization permissions, Guests, set Who can view all other users in the organization to Admins, moderators and members.
Organization settings > Users lists every account, where you can change a role or deactivate someone who leaves.
8. Channels, topics and messages
Zulip starts with three channels: general, sandbox and Zulip, the last two with tips for new users. Create a channel in the left sidebar adds one. Under Who can access this channel, choose Public for a channel anyone in the organization can join, or Private for one only its members see.
Every message in a channel needs a topic. When you write, the compose box shows the channel and a topic, such as sales > Q4 offers: pick an existing topic to reply, or type a new one to start a conversation. The left sidebar lists the topics under each channel, and Inbox shows what you have not read, by topic.
Some things to know:
- New direct message at the bottom writes to one person or a small group, outside any channel.
@**Bo Berg**mentions a person, and they see the message under Mentions. Zulip completes the name as you type@.- The paper clip in the compose box uploads a file, up to 100 MB. Files can only be opened by people who can see the message: others are sent to the login page.
- Search at the top searches the text of every message you can see. A search for
thursdayfound a message containing "Thursday". - To edit your message, hover over it and choose the pencil, Edit message. Edited messages are marked EDITED, and clicking that shows every earlier version.
- Group settings in the gear menu creates user groups, such as
sales-team.@*sales-team*mentions everyone in it.
9. Use Zulip in Swedish
Zulip comes with a Swedish translation, but only about 9 % of it is done in Zulip 12.3: menus are a mix of Swedish and English, and emails are in English. Each user picks Svenska under Personal settings > Preferences > Language, in the gear menu. Swedish users will also want Time format set to the 24-hour clock in the same place.
To make Swedish the default for new users, open Organization settings > Organization settings and set Language for automated messages and invitation emails to Svenska. Under Default user settings, you can set the 24-hour clock for new users. Users who already have an account keep their language.
10. Webhooks and bots
Zulip has ready-made integrations for over 130 services, listed under Integrations in the gear menu, and each one comes with instructions. Many monitoring and alert tools can also send Slack-style webhooks, which Zulip accepts:
- Go to Personal settings > Bots > Add a new bot, choose Incoming webhook as Bot type, and give it a name, such as
Monitoring. Zulip shows its API key. - Point the tool at
https://chat.example.com/api/v1/external/slack_incoming?api_key=THE-KEY&stream=general&topic=alerts.
A test with curl, as a monitoring tool would send it, posted "Backup job on db1 failed at 03:00" in general under the topic alerts:
curl -s "https://chat.example.com/api/v1/external/slack_incoming?api_key=THE-KEY&stream=general&topic=alerts" \
-H "Content-Type: application/json" -d '{"text":"Backup job on db1 failed at 03:00"}'
For your own scripts, choose Generic bot instead. It gets an email address, such as report-bot@chat.example.com, and an API key, and can do what a user can through Zulip's API:
curl -s -u "report-bot@chat.example.com:THE-KEY" https://chat.example.com/api/v1/messages \
-d type=stream -d to=general -d topic="daily report" --data-urlencode "content=12 new orders yesterday."
Zulip's API documentation, in the gear menu, shows the same calls in Python and JavaScript. Keep bot keys as secret as passwords: anyone with one can post as the bot.
A tool on this same server, such as Uptime Kuma, reaches Zulip by its public name only with one of the fixes in step 8 of the Podman guide, on the tool's side.
11. Mobile apps and push notifications
Zulip's apps for Android and iOS, and its desktop apps, log in to https://chat.example.com like a browser. We did not test them.
Notifications on a phone are different. Apple and Google only let the maker of an app send it notifications, so a self-hosted Zulip sends them through Zulip's Mobile Push Notification Service, at push.zulipchat.com, run by Kandra Labs. This is what we found in Zulip's documentation and price list on 14 October 2026:
- It is free for organizations with up to 10 users. Larger ones need a paid plan for the self-hosted server: Basic at $3.50 per user and month, or Business at $6.67 per user and month billed yearly, from 25 users. Non-profits, open-source projects, research and similar communities can get it free. The paid plans add nothing else to the software; Business and Enterprise also include support.
- With Zulip 12 and an up-to-date app, notifications are encrypted from your server to the phone. The service, Apple and Google see the device, when a notification is sent, and how large it is, but not the message, the sender, the channel or the topic.
- A registered server sends the service its hostname, the
ZULIP_ADMINISTRATORaddress, and for each organization basic details such as its name and login methods, its type, creation date and number of users in each role. By default it also sends usage statistics: how many messages are sent and read, and how many users are active.
To use it, add this line to zulip-app.container and restart as in step 6. The second setting turns off the usage statistics:
Environment=SETTING_ZULIP_SERVICE_PUSH_NOTIFICATIONS=True SETTING_ZULIP_SERVICE_SUBMIT_USAGE_STATISTICS=False
Then register the server:
podman exec -it -u zulip zulip-app /home/zulip/deployments/current/manage.py register_server
It lists what it will send, and asks you to accept Zulip's Terms of Service. We ran it up to that question and answered no, so push notifications were not tested. Under Organization settings > Organization settings, Require end-to-end encryption for push notifications keeps message text out of notifications to old apps that cannot decrypt.
Without the service, the apps show new messages when opened, but phones are not notified.
12. Outside services
We captured the server's DNS lookups and outgoing connections from the first start on, through restarts, a reboot and everyday use. Apart from downloading the images from docker.io and ghcr.io, Zulip contacted nothing outside, and a browser using it loaded nothing from other sites. At each start, the configuration tool in Zulip's image tries 169.254.169.254, the address where cloud providers answer questions about a server. It is not on the internet, and nothing answered.
Without the lines from step 3, users' browsers fetch profile pictures and the organization's icon from Gravatar, run by Automattic in the United States, sending a hash of each email address, and new users' welcome video from static.zulipchat.com.
Some things reach outside when they are used:
- When someone posts a link to an image, the server fetches it to show a preview. Readers' browsers get it from your server, not the other site. In our test, a link to an image on
www.debian.orgwas fetched by the server when the message was sent. Show previews of linked images and videos under Organization settings > Organization settings turns it off. - Show previews of linked websites, in the same place, is off by default. On, the server fetches every linked page.
- The video and phone buttons in the compose box create a call on
meet.jit.si, Jitsi's public server, run by 8x8 in the United States. Call provider in the same place switches them off, or points them at your own Jitsi Meet with a custom URL. - Push notifications go through
push.zulipchat.com, once you turn them on (step 11).
13. Back up
As zulip:
umask 077
mkdir -p ~/backup
podman exec zulip-db pg_dump -U zulip zulip > ~/backup/zulip.sql
podman volume export zulip-data > ~/backup/zulip-data.tar
That saves the messages, users, channels and settings, the uploaded files, and Zulip's own keys, without which the uploads and logins of a restored server would not work. The dump runs while Zulip keeps working. umask 077 makes the new files readable by zulip only. Copy ~/backup to another machine, for example with restic, and keep it private: it holds every message.
To restore, as zulip, with the backup in ~/backup:
systemctl --user stop zulip-app
podman exec zulip-db dropdb -U zulip --force zulip
podman exec zulip-db createdb -U zulip zulip
podman exec -i zulip-db psql -q -U zulip zulip < ~/backup/zulip.sql > /dev/null
podman volume rm zulip-data
podman volume create zulip-data
podman volume import zulip-data ~/backup/zulip-data.tar
systemctl --user start zulip-app
On a new server, run steps 1 to 4 first, then restore. The passwords from step 2 are not taken from the backup: Zulip uses the new secrets when it starts. We restored a backup this way onto a freshly set up Zulip, and every user could log in with their old password, with their messages, groups and files.
14. Update
Once a day, podman-auto-update.timer fetches new images for PostgreSQL 14, RabbitMQ 4.2, Redis and memcached, and restarts the pod when one has changed, which takes Zulip offline for a minute and a half.
Zulip itself has no tag that follows new releases: each image has a tag of its own, such as 12.3-0, listed on Zulip's releases page. To update, take a backup, change the tag in zulip-app.container, and restart:
podman pull ghcr.io/zulip/zulip-server:12.4-0
sed -i 's/zulip-server:12.3-0/zulip-server:12.4-0/' ~/.config/containers/systemd/zulip-app.container
systemctl --user daemon-reload
systemctl --user restart zulip-app
podman image prune -f
Zulip updates its database by itself when it starts. Pulling the image first keeps the downtime to the restart. Read the release notes before a new major version, such as 13.
Zulip's PostgreSQL image is PostgreSQL 14, which stops getting fixes from its developers in November 2026, and Zulip had not published a newer one when we wrote this. The database can only be reached inside the pod. When a newer image comes, moving to it is a backup and a restore into an empty zulip-db volume.
Troubleshooting
Every page says "Internal server error", and podman exec zulip-app tail /var/log/zulip/errors.log says "Reverse proxy misconfiguration". LOADBALANCER_IPS is missing or names the wrong address. It must be the server's IPv4 address, not 127.0.0.1. Fix it in zulip-app.container, then daemon-reload and restart zulip-app.
Caddy answers 502 for a minute or two. Zulip is still starting. podman logs -f zulip-app shows how far it has come.
"Organization creation link expired or invalid". The link from step 5 has been used, even if you did not finish. Make a new one.
Zulip says an invitation or a password reset was sent, but nothing arrives. Email is not set up, or the provider refuses it. send_test_email from step 6 shows the provider's answer, and podman exec zulip-app tail /var/log/zulip/send_email.log lists what Zulip tried to send.
"Rate limit exceeded" when people sign up with the invitation link. Zulip sends at most five confirmation emails a day for sign-ups from one IP address. Invite the rest by email.