Applications AWS

Zulip Threaded Team Chat Server on AWS User Guide

| Product: Zulip on AWS

Overview

This guide covers the deployment and configuration of Zulip on AWS using cloudimg AWS Marketplace AMIs. Zulip is an open source team chat application built around threaded conversations: messages live in channels and, inside a channel, in named topics, so several discussions run in parallel without interleaving. That structure is what makes Zulip readable asynchronously — people catch up topic by topic instead of scrolling a single firehose.

A production Zulip server is not one process. It is a PostgreSQL database with a full text search extension, a RabbitMQ message broker, a Redis instance, a memcached cache, a Django application server, a Tornado real time push server, a pool of queue workers and an nginx front door terminating TLS. This image installs all of it with the vendor's own production installer, from the official upstream release tarball verified against a pinned SHA-256 digest, so what you run is unmodified upstream Zulip.

No secret in this image is shared between instances. Zulip mints its secrets when it is installed — the Django signing key, the avatar salt, the shared secret, the camo key, and the RabbitMQ, memcached and Redis passwords. An image that simply captured that state would give every customer the same Django signing key, which means a session token minted on one deployment would be valid on all of them. All of it is stripped before capture, together with the TLS private key, the RabbitMQ distribution cookie and the memcached authentication database, and regenerated on the first boot of your instance, before nginx is allowed to start.

What is included:

  • Zulip Server 12.1, installed from the official upstream production tarball (zulip-server-12.1.tar.gz, SHA-256 320a3a41…f229, verified byte identical from download.zulip.com and the GitHub release asset)

  • PostgreSQL 18 with the pgroonga extension for full text search, on its own dedicated EBS volume

  • RabbitMQ, Redis and memcached, all bound to loopback only

  • The Django application server, the Tornado real time push server and the queue workers, under supervisor

  • nginx terminating TLS on port 443, with plain HTTP on port 80 redirecting to HTTPS

  • A per instance self signed TLS certificate, minted on first boot for that instance's own address

  • An organization and owner account created on first boot, with a 24 character password generated on the instance and written to a root only file

  • Invitation only registration, with invitations restricted to administrators

  • Two dedicated EBS volumes — the database at /var/lib/postgresql and the whole application tier (deployments, virtual environment and uploaded files) at /home/zulip

  • The verbatim upstream Apache-2.0 licence text baked into the image at /usr/local/share/cloudimg-zulip/

Nothing in this image runs in a container. Every component is a native package managed by systemd and supervisor.

Connecting to your instance

Connect over SSH on port 22 using the key pair you selected at launch. The login user depends on the operating system variant you launched:

AMI variant Operating system SSH login user
Zulip on Ubuntu 24.04 Ubuntu 24.04 LTS ubuntu
ssh -i /path/to/your-key.pem ubuntu@<public-ip>

Confirm the platform is running

Every unit should report active. zulip-firstboot.service is the one shot that generated this instance's secrets, certificate and organization.

systemctl is-active postgresql.service redis-server.service rabbitmq-server.service memcached.service supervisor.service nginx.service zulip-firstboot.service
active
active
active
active
active
active
active

Only SSH and the two nginx ports are reachable from the network. The database, the message broker and the caches are bound to loopback and cannot be reached from outside the instance.

sudo ss -tlnH | awk '{split($4,a,":"); p=a[length(a)]; if (p=="22"||p=="80"||p=="443"||p=="5432"||p=="6379"||p=="5672"||p=="11211") print $4}' | sort -u
0.0.0.0:22
0.0.0.0:443
0.0.0.0:80
127.0.0.1:11211
127.0.0.1:5432
127.0.0.1:5672
127.0.0.1:6379
[::1]:6379
[::]:22
[::]:443
[::]:80

The application processes run under supervisor:

sudo supervisorctl status | awk '{printf "%-52s %s\n", $1, $2}' | head -8
go-camo                                              RUNNING
process-fts-updates                                  RUNNING
smokescreen                                          RUNNING
zulip-django                                         RUNNING
zulip-katex                                          RUNNING
zulip-tornado                                        RUNNING
zulip-tus                                            RUNNING
zulip-workers:zulip_events_deferred_email_senders    RUNNING

Zulip's own health endpoint answers over TLS, and the installed version is reported by the deployment:

curl -sk -o /dev/null -w 'https://127.0.0.1/health -> HTTP %{http_code}\n' https://127.0.0.1/health
grep -oE "ZULIP_VERSION = \"[0-9.]+\"" /home/zulip/deployments/current/version.py
https://127.0.0.1/health -> HTTP 200
ZULIP_VERSION = "12.1"

Retrieve your organization owner credentials

The owner account and its password were generated on this instance's first boot and written to a root only file. Read the whole file with sudo cat /root/zulip-credentials.txt — it contains the sign in URL, the owner email address and the password.

The block below shows the file's shape and permissions without printing the password:

grep -E '^zulip\.(url|admin\.email)=' /root/zulip-credentials.txt
awk -F= '/^zulip\.admin\.pass=/{print "zulip.admin.pass  (" length($2) " characters, unique to this instance)"}' /root/zulip-credentials.txt
stat -c '%n  mode %a  owner %U' /root/zulip-credentials.txt
zulip.url=https://<public-ip>/
zulip.admin.email=admin@<public-ip>.nip.io
zulip.admin.pass  (24 characters, unique to this instance)
/root/zulip-credentials.txt  mode 600  owner root

The owner email address uses the instance's own address so that a working login exists before you have configured a domain name or outbound email. You can change it at any time from Personal settings → Account & privacy.

Sign in

Browse to https://<public-ip>/ and accept the browser warning about the self signed certificate (replacing it with a certificate from a trusted authority is covered below). Sign in with the owner email address and password from the credentials file.

Zulip sign in page

Note the line under the sign in box: "You need to be invited to join this organization." This image ships invitation only, so a Zulip server on a public address does not accept anonymous signup.

On your very first sign in Zulip shows a one time welcome screen confirming your email address — click Continue. Zulip then offers a short video tour, which you can skip.

Channels and topics

The Inbox view is Zulip's home screen. It lists every channel that has unread messages and, under each one, the individual topics within it, so you can see at a glance which conversations moved.

Zulip inbox showing channels and topics

Opening a topic shows just that conversation. This is the core difference from a flat chat tool: a release discussion, an incident and an onboarding question can all be live in #general at once without interleaving, and each can be resolved, muted, linked to or moved independently.

A threaded conversation inside a Zulip channel topic

To create a channel, use CREATE A CHANNEL at the bottom of the left sidebar. To start a conversation, click Start new conversation, name the topic, and send.

Organization administration

Open the gear menu in the top right and choose Organization settings. The Users section lists members and their roles, and lets you invite people, change roles and deactivate accounts. Organization permissions controls who may invite, who may create channels, and the message editing and retention policies.

Zulip organization settings, users list

Your account is the organization Owner. Create at least one more owner before you rely on this server, so you are not locked out if the account is lost.

Verify every secret is unique to your instance

Zulip's secrets live in /etc/zulip/zulip-secrets.conf. This image ships that file empty and regenerates the whole set on first boot, along with the TLS private key, the RabbitMQ distribution cookie and the memcached authentication database.

The helper below prints a SHA-256 fingerprint of each value — never the value itself — so you can confirm the set exists and, if you launch a second instance from the same AMI, confirm that no fingerprint matches:

sudo /usr/local/sbin/zulip-secret-fingerprints.sh | awk '{printf "%-40s %s...\n", $1, substr($2,1,16)}'
avatar_salt                              db2e0de26246bfb3...
camo_key                                 a740231a88e4ae54...
memcached_password                       e104df7659318487...
rabbitmq_password                        adb135d8d142b1e5...
redis_password                           734978561ebc43d6...
secret_key                               44b05b2693683847...
shared_secret                            20363002338fd7e0...
zulip_org_id                             8392c4ef54ae5dbd...
zulip_org_key                            281d48b050486b71...
file:/etc/ssl/private/zulip.key          89e405fb27c095f4...
file:/var/lib/rabbitmq/.erlang.cookie    c2cf7860848ac179...
file:/etc/sasl2/memcached-sasldb2        b97478c716f9ea1f...

Sign in attempts are recorded by Zulip's authentication backend, so you can audit them:

sudo awk '/Authentication attempt/{n++} END{print n+0 " sign-in attempts recorded in /var/log/zulip/auth.log"}' /var/log/zulip/auth.log
37 sign-in attempts recorded in /var/log/zulip/auth.log

Storage layout

The database and the application tier sit on their own EBS volumes, mounted by filesystem UUID so the layout reproduces on every instance. Either can be grown with the EC2 console and resize2fs without rebuilding the server.

findmnt -no TARGET,SOURCE,SIZE,USED /var/lib/postgresql
findmnt -no TARGET,SOURCE,SIZE,USED /home/zulip
findmnt -no TARGET,SOURCE,SIZE,USED /
/var/lib/postgresql /dev/nvme1n1 39.1G 73.2M
/home/zulip /dev/nvme2n1 39.1G  4.8G
/      /dev/nvme0n1p1 37.7G  5.5G

/var/lib/postgresql holds the PostgreSQL cluster — every channel, topic, message and user. /home/zulip holds the versioned deployment tree, the Python virtual environment, the compiled static assets and LOCAL_UPLOADS_DIR, where every uploaded file, image and avatar is stored.

Use your own domain name and a trusted certificate

The instance answers at its own IP address with a self signed certificate. For production, point a DNS name at the instance and install a certificate signed by a trusted authority. This is the first thing to do after launch, because Zulip embeds EXTERNAL_HOST in message links, uploaded file URLs and email footers.

Step What to do
1 Create an A record for your chosen name (for example chat.your-company.example) pointing at the instance's public IP, and allocate an Elastic IP so the address does not change.
2 Edit /etc/zulip/settings.py and set EXTERNAL_HOST = "chat.your-company.example".
3 Obtain a certificate. Zulip ships the vendor's Certbot helper: sudo /home/zulip/deployments/current/scripts/setup/setup-certbot --email you@your-company.example chat.your-company.example (port 80 must be reachable from the internet for the HTTP-01 challenge). To install a certificate you already hold, place the full chain at /etc/ssl/certs/zulip.combined-chain.crt and the private key at /etc/ssl/private/zulip.key.
4 Apply it: sudo -u zulip /home/zulip/deployments/current/scripts/restart-server then sudo systemctl reload nginx.
5 Reload https://chat.your-company.example/ and confirm the certificate is trusted.

Configure outbound email

Outbound email is not configured on this image, and it will not work until you configure it. Zulip uses email for invitations, password resets and message notifications. Two things stand in the way on a fresh EC2 instance: AWS blocks outbound TCP port 25 by default, and a new instance has no reverse DNS record or sending reputation, so mail it sent directly would be rejected or filed as spam. The honest answer is to relay through a service that is already trusted.

Your owner account does not depend on email — its password was generated on the instance and written to /root/zulip-credentials.txt — so you can sign in and configure everything below first.

Step What to do
1 In Amazon SES, verify your sending domain (or a single sender address) and create SMTP credentials. A new SES account is in the sandbox and can only send to verified addresses until you request production access.
2 Edit /etc/zulip/settings.py and set EMAIL_HOST = "email-smtp.<region>.amazonaws.com", EMAIL_HOST_USER = "<your SES SMTP username>", EMAIL_PORT = 587, EMAIL_USE_TLS = True and NOREPLY_EMAIL_ADDRESS = "noreply@your-company.example".
3 Add the SMTP password as a secret: append smtp_password = <your SES SMTP password> to /etc/zulip/zulip-secrets.conf (root only, mode 640). Do not put the password in settings.py.
4 Apply and test: sudo -u zulip /home/zulip/deployments/current/scripts/restart-server then sudo -u zulip /home/zulip/deployments/current/manage.py send_test_email you@your-company.example.
5 Any authenticated SMTP relay works equally well — SES is simply the least effort from EC2.

Until this is done, add people with Invite links copied from the Users screen rather than emailed invitations, and reset passwords from the command line with sudo -u zulip /home/zulip/deployments/current/manage.py change_password <email>.

Back up your organization

Zulip ships its own backup command, which captures the database, the uploaded files and the configuration in one archive. Write backups to a volume other than the instance, and copy them to S3.

Task Command
Full backup sudo -u zulip /home/zulip/deployments/current/manage.py backup --output /home/zulip/zulip-backup.tar.gz
Copy to S3 aws s3 cp /home/zulip/zulip-backup.tar.gz s3://your-bucket/zulip/
Restore sudo /home/zulip/deployments/current/scripts/setup/restore-backup /path/to/zulip-backup.tar.gz
Export one organization (portable, for migration) sudo -u zulip /home/zulip/deployments/current/manage.py export -r ''

An EBS snapshot of both data volumes taken together is a good complement, but it is not a substitute for a Zulip backup archive, which is what the restore tooling consumes.

Upgrade Zulip

Zulip is upgraded in place with the vendor's own upgrade script, which handles the database migrations and restarts the stack. Take a backup first.

Task Command
Check the running version grep ZULIP_VERSION /home/zulip/deployments/current/version.py
Upgrade to the latest release sudo /home/zulip/deployments/current/scripts/upgrade-zulip-from-git main or download a release tarball and run sudo /home/zulip/deployments/current/scripts/upgrade-zulip /path/to/zulip-server-X.Y.tar.gz
Operating system security updates sudo apt-get update && sudo apt-get -y upgrade

Troubleshooting

Symptom What to check
The browser warns about the certificate Expected until you install a trusted certificate — the image ships a per instance self signed one. Follow the domain name section above.
Sign in page loads but the app does not sudo supervisorctl statuszulip-django and zulip-tornado must both be RUNNING. Restart with sudo -u zulip /home/zulip/deployments/current/scripts/restart-server.
"Invalid HTTP_HOST header" or links point at the wrong address EXTERNAL_HOST in /etc/zulip/settings.py does not match the address you are browsing to. Correct it and restart the server.
Invitations or password reset emails never arrive Outbound email is not configured — see the email section above. Test with manage.py send_test_email.
Messages send but nothing appears in real time Check zulip-tornado under supervisor and look at /var/log/zulip/tornado.log.
Uploads fail Check free space on /home/zulip with df -h /home/zulip and grow the volume if needed.
Where are the logs /var/log/zulip/server.log for the application, auth.log for sign in attempts, errors.log for tracebacks, tornado.log and workers.log for the real time and queue tiers.

Licence

Zulip Server is licensed under the Apache License 2.0. The verbatim licence text, the upstream NOTICE, the BSD-3-Clause notice for the vendored confirmation package and the source provenance of the exact release shipped are all on the image:

ls /usr/local/share/cloudimg-zulip/
head -2 /usr/local/share/cloudimg-zulip/LICENSE
LICENSE
LICENSE.confirmation-bsd3.txt
NOTICE
SOURCE.txt

                                 Apache License

Zulip Cloud and Zulip's paid self hosted plans are the vendor's separate commercial offerings and are not included with this image. cloudimg is not affiliated with or endorsed by Zulip or Kandra Labs; the Zulip name and logo are used to identify the open source software shipped here.

Support

Every cloudimg image is backed by 24/7 technical support by email and live chat. We aim to respond within one business day.

  • Email: support@cloudimg.co.uk
  • Support: https://www.cloudimg.co.uk/support