What you will set up
Zammad is a helpdesk: every email to your support address, such as support@example.com, becomes a ticket. Your team answers in the browser, the customer gets the answer as an ordinary email, and their reply lands in the same ticket. Tickets can be assigned, tagged, merged and searched, and Zammad keeps track of how fast you answer.
Zammad is developed by Zammad GmbH, a company in Berlin that also sells hosted Zammad and support, and the code belongs to the Zammad Foundation. It is open source under the AGPL 3.0 licence. In our captures of the server's traffic it made no update check and sent no usage data. Out of the box it uses two outside services run by Zammad: it sends the email address of each new customer to images.zammad.com to look for a profile picture, and the address of each login to geo.zammad.com to show where it came from. Step 8 switches both off. During setup, Zammad also asks geo.zammad.com where the server is, to suggest a calendar of public holidays, and from then on fetches those holidays from calendar.google.com once a day; step 8 covers that too.
Zammad is not a mail server. It fetches the mail sent to your support address from a mailbox at your email provider, over IMAP, and sends its replies through the same provider over SMTP, so your mail keeps going through the provider you already use.
Here Zammad runs as a pod of eight containers under a user of its own called zammad, behind Caddy from the Podman guide: PostgreSQL for the data, Redis, Memcached, Elasticsearch for search, and Zammad's web server, background worker, websocket server and web front, with a setup job that runs before them. They are the containers of Zammad's official Docker Compose setup.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Zammad 7.2.1 started with PostgreSQL 17 and Elasticsearch 9.5.5, and the admin account was created through an SSH tunnel before Caddy was set up.
- An email to the support address became a ticket, with an automatic reply to the customer. An agent's answer arrived in the customer's mailbox, and the customer's reply landed in the same ticket.
- Zammad sent mail over SMTP with STARTTLS on port 587 and with TLS on port 465, both against test mail servers on the same server. A new-ticket notification and a password reset arrived.
- Search found tickets by a word in their text. The interface was shown in Swedish.
- Zammad logged each visitor's real address, over IPv4 and IPv6, and ignored a forged
X-Forwarded-Forheader. - With Zammad's two services switched off, a ticket from a new customer caused no outside lookups.
- Zammad was updated from 7.2.1 to 7.2.2 with
podman auto-update. It was restored from a backup into empty volumes, ran without Elasticsearch, and came back by itself after a reboot.
The pod used about 2.5 GB of memory, 1.5 GB of it Elasticsearch, so it fits on 8 GB with room to spare. Elasticsearch is optional, but Zammad's documentation recommends it for search, and Zammad's reports need it. Without it, as in Troubleshooting, the pod used about 800 MB, and search still found tickets by words in their text.
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
help.example.compointing at your server; - a mailbox for your support address at your email provider, with IMAP and SMTP access, and its server names and password. If the account has two-factor authentication, the provider usually gives you an app password for this.
The examples use help.example.com for Zammad, support@example.com for the support address, mail.example.com for your provider's mail server and 203.0.113.10 for your server's IPv4 address, which ip -brief address show eth0 shows. Replace them throughout.
1. Create the user
As root:
useradd -m -s /bin/bash zammad
loginctl enable-linger zammad
machinectl shell zammad@
Steps 2 and 3 run as zammad.
2. Create the secret
openssl rand -hex 24 | tr -d '\n' | podman secret create zammad-db-password -
That makes the database password. Podman stores it, not the files below.
3. Define the pod
mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd
Create zammad.pod:
[Pod]
PodName=zammad
# Only Caddy, on this server, can reach Zammad: the port is not open to the internet.
PublishPort=127.0.0.1:8147:8080
[Install]
WantedBy=default.target
The database, zammad-db.container:
[Container]
ContainerName=zammad-db
Image=docker.io/library/postgres:17-alpine
Pod=zammad.pod
Volume=zammad-db:/var/lib/postgresql/data
Environment=POSTGRES_USER=zammad POSTGRES_DB=zammad_production
Secret=zammad-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U zammad -d zammad_production
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
Redis, zammad-redis.container:
[Container]
ContainerName=zammad-redis
Image=docker.io/library/redis:8-alpine
Pod=zammad.pod
AutoUpdate=registry
[Service]
Restart=always
Memcached, zammad-memcached.container:
[Container]
ContainerName=zammad-memcached
Image=docker.io/library/memcached:1.6-alpine
Pod=zammad.pod
Exec=memcached -m 256M
AutoUpdate=registry
[Service]
Restart=always
Elasticsearch, zammad-search.container:
[Container]
ContainerName=zammad-search
Image=docker.io/library/elasticsearch:9.5.5
Pod=zammad.pod
Volume=zammad-search:/usr/share/elasticsearch/data
Environment=discovery.type=single-node xpack.security.enabled=false ingest.geoip.downloader.enabled=false
Environment="ES_JAVA_OPTS=-Xms1g -Xmx1g"
[Service]
Restart=always
Zammad's setup job, zammad-init.container, which creates the database on the first start and updates it after each update:
[Unit]
Requires=zammad-db.service zammad-redis.service zammad-memcached.service zammad-search.service
After=zammad-db.service zammad-redis.service zammad-memcached.service zammad-search.service
[Container]
ContainerName=zammad-init
Image=ghcr.io/zammad/zammad:7.2
Pod=zammad.pod
Exec=zammad-init
EnvironmentFile=%h/zammad.env
Secret=zammad-db-password,type=env,target=POSTGRESQL_PASS
Volume=zammad-storage:/opt/zammad/storage
[Service]
Type=oneshot
TimeoutStartSec=15min
Zammad itself runs as four containers from the same image, which differ only in their name. This loop creates the four files, zammad-railsserver.container, zammad-scheduler.container, zammad-websocket.container and zammad-nginx.container:
for s in railsserver scheduler websocket nginx; do
cat > zammad-$s.container <<EOF
[Unit]
Wants=zammad-init.service
After=zammad-init.service
[Container]
ContainerName=zammad-$s
Image=ghcr.io/zammad/zammad:7.2
Pod=zammad.pod
Exec=zammad-$s
EnvironmentFile=%h/zammad.env
Secret=zammad-db-password,type=env,target=POSTGRESQL_PASS
Volume=zammad-storage:/opt/zammad/storage
AutoUpdate=registry
[Service]
Restart=always
EOF
done
Last, the settings they share, in ~/zammad.env:
ZAMMAD_FQDN=help.example.com
ZAMMAD_HTTP_TYPE=https
NGINX_SERVER_SCHEME=https
RAILS_TRUSTED_PROXIES=127.0.0.1,::1,203.0.113.10
TZ=Europe/Stockholm
POSTGRESQL_HOST=127.0.0.1
POSTGRESQL_DB_CREATE=false
REDIS_URL=redis://127.0.0.1:6379
MEMCACHE_SERVERS=127.0.0.1:11211
ELASTICSEARCH_HOST=127.0.0.1
ZAMMAD_RAILSSERVER_HOST=127.0.0.1
ZAMMAD_WEBSOCKET_HOST=127.0.0.1
What the settings do:
ZAMMAD_FQDNandZAMMAD_HTTP_TYPEgive Zammad its public address, which it puts in links and emails. They are read on the first start only; later, the address is under Settings > System > Base in Zammad.NGINX_SERVER_SCHEME=httpstells Zammad that visitors use HTTPS, which Caddy takes care of. Without it, logins fail with "CSRF token verification failed".RAILS_TRUSTED_PROXIESlists the addresses Zammad trusts to tell it the visitor's real address. 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 add.- The containers share the pod's network, so they reach each other on
127.0.0.1, and only the web front's port 8080 is published. ghcr.io/zammad/zammad:7.2follows every patch release of Zammad 7.2. PostgreSQL stays on 17 and Redis on 8. Elasticsearch has no tag that follows a version line, so its version is fixed; step 10 explains how to move it.- Elasticsearch gets 1 GB for its data, as in Zammad's own setup. It runs as a single node without a password, since only the pod can reach it, and does not download location databases from Elastic.
Start it:
systemctl --user daemon-reload
systemctl --user start zammad-pod
systemctl --user enable --now podman-auto-update.timer
The first start downloads about 3 GB of images, and the setup job then takes about five minutes to create the database and the search index. It is ready when this prints 200:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8147/
systemctl --user status zammad-init shows the setup job. It ends as inactive (dead), which is right: it runs, then stops.
4. Create the admin account
Until Zammad is set up, whoever opens it first creates the admin account. That is why its port is on 127.0.0.1 only, and why you reach it through SSH for now. From your own computer:
ssh -L 8147:127.0.0.1:8147 root@203.0.113.10
Keep that session open, and open http://localhost:8147 in your browser. Zammad shows "This isn't the address Zammad is set up for", which is right, since it is set up for help.example.com; ignore it.
- Choose Set up a new system.
- Enter your name, email address and a password, and choose Create.
- Enter your organisation's name as Organization Name. System URL already says
https://help.example.com. Choose Next. - Under Email Notification, choose Skip, and under Connect Channels, Skip again. Step 6 sets up email.
Zammad opens its dashboard. It takes its time zone from your browser; you can check it under Settings > Branding > Timezone. Close the SSH session.
5. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
help.example.com {
reverse_proxy 127.0.0.1:8147
}
Restart Caddy with systemctl --user restart caddy, open https://help.example.com, and log in with the account from step 4.
Zammad's settings are under the gear icon at the bottom left, in four sections: Manage, Channels, Settings and System. The steps below name the section first.
6. Email
Email is how tickets come in and answers go out. Zammad needs two things from your email provider: the support mailbox, which it reads over IMAP and sends answers from over SMTP, and an account to send its own notifications to your team, such as new tickets and password resets. Both can be the same account.
Zammad stores these passwords in its database, not as Podman secrets: you enter them in its settings, and they are in your backups (step 9).
The support mailbox. Under Channels > Email, choose New under Email Accounts. Enter the name your customers will see as Organization & Department Name, such as Example Support, then the address, its password, and Users as Destination Group. Keep Destination group > Sending email address at Change to channel email address: the group then sends its answers and automatic replies from this address. Choose Connect. For well-known providers, Zammad finds the server settings by itself. If it cannot, it asks for them:
- Email Inbound: Type IMAP, your provider's Host, such as
mail.example.com, the full address as User, SSL/STARTTLS SSL and Port993. - Keep Messages on Server: with no, Zammad deletes each email from the mailbox once it is a ticket. Choose yes to keep it there; Zammad then marks it as read.
- Email Outbound: the same Host and User, and Port
587, which uses STARTTLS, or465, which uses TLS from the start.
Choose Continue. Zammad sends a test email to the mailbox and waits for it to come back before it saves the account, which takes up to a minute. After that, it fetches new mail about every 30 seconds.
Notifications. On the same page, under Email Notification, choose Edit next to Outbound, enter your provider's SMTP Host, User, Password and Port 587, and choose Continue. When you save, Zammad sends a test email to verify-external-smtp-sending@discard.zammad.org, an address of Zammad's that throws it away. Then, under the Settings tab, set Notification Sender to an address the account may send from, such as Example Helpdesk <support@example.com>, and Submit. Zammad's default, noreply@help.example.com, is not an address your provider sends for.
Signature. Every answer gets the signature under the Signatures tab, and the one Zammad ships with is an example, with an address in Miami. Open default, write your own, and Submit.
For Gmail and Microsoft 365, Google Email and Microsoft 365 Graph Email under Channels connect with the provider's own login instead of a password. We did not test them.
Without email set up:
- no tickets come in by email, and an answer to a ticket can only be an internal note;
- inviting a colleague under First Steps on the dashboard shows "Can't create user!" with an SMTP error, although the account is created;
- Forgot password? says the instructions were sent, but nothing arrives.
Tickets created in the browser, and logins with a password, keep working.
7. Agents and customers
Agents answer tickets. Add them under Manage > Users > New User: tick Agent under Roles, and full for the groups they work in under Group Permissions. Zammad sends them no email; set a Password for them, or let them use Forgot password? on the login page.
Customers are the people who write to you. Each new address that emails the support address gets a customer account by itself. By default, customers can also log in at https://help.example.com to see their tickets and open new ones: Register as a new customer on the login page lets anyone create an account, and Forgot password? lets anyone who has written to you set a password. If you only want tickets by email, set New User Accounts to no under Settings > Security > Base. The Two-factor Authentication tab next to it turns on two-factor logins.
To answer a ticket, open it from Overviews, choose the reply arrow under the customer's message, write, and choose Update. The lock icon makes it an internal note that only your team sees. The search box at the top finds tickets by any word in them.
8. Swedish, and outside services
Zammad comes translated into Swedish. Each user picks Svenska under their avatar > Profile > Language. To make Swedish the default for everyone who has not picked a language, choose it under Settings > Branding > Locale, and Submit. The login page follows the language of the visitor's browser.
Zammad also creates a calendar of business hours and public holidays, which escalation times and SLAs count in. It asks Zammad's location service where the server is, and on ours it made a calendar called France, in Paris time with French holidays. Under Manage > Calendars, choose Edit, set Name to Sweden, Time Zone to Europe/Stockholm and Subscribe to public holidays in to Sweden, or your own country, and Submit. Zammad then fetches the holidays from calendar.google.com once a day. The Swedish list includes days you probably work, such as Valentine's Day and Walpurgis Night: untick them under Holidays.
Under Settings > System > Services, set Image Service and Geo IP Service to -, and Submit each:
- Image Service sends the email address of each new customer to
images.zammad.com, for a profile picture. In our capture, a ticket from a new address made the server look upimages.zammad.com; with the service off, it made no lookups at all. - Geo IP Service sends the IP address of each login to
geo.zammad.com, to show where it came from under System > Sessions.
Geo Location Service looks up street addresses you enter for users at OpenStreetMap. Geo Calendar Service is what asked geo.zammad.com where the server is during setup; Zammad does not use it after that. You can set both to - as well.
9. Back up
As zammad:
umask 077
mkdir -p ~/backup
podman exec zammad-db pg_dump -U zammad zammad_production > ~/backup/zammad-db.sql
podman volume export zammad-storage > ~/backup/zammad-storage.tar
The tickets, their attachments, users and settings, with the email passwords, are in the database. The zammad-storage volume only holds attachments if you set Storage Method to Filesystem under Settings > System > Storage. The search index is not needed: Zammad builds it again from the database. The dump runs while Zammad keeps working. Copy ~/backup to another machine, for example with restic, and store it safely.
To restore into an empty pod, as zammad, with the backup in ~/backup:
systemctl --user stop zammad-pod
podman volume rm zammad-db zammad-storage zammad-search
podman volume create zammad-storage
podman volume import zammad-storage ~/backup/zammad-storage.tar
systemctl --user start zammad-db
podman exec -i zammad-db psql -q -o /dev/null -U zammad zammad_production < ~/backup/zammad-db.sql
systemctl --user start zammad-pod
Starting only the database first loads the backup before the setup job runs. The setup job then finds an existing database and builds the search index again, and Zammad answers after about three minutes.
10. Update
podman auto-update checks once a day for new images of Zammad 7.2, PostgreSQL 17, Redis 8 and Memcached. When one has changed, it restarts the whole pod, and the setup job updates the database before Zammad starts. To update now:
podman auto-update
Zammad is unavailable for three or four minutes while it restarts. For a new minor version, such as 7.3, read its release notes, make a backup, and change the tag in the five files that use it:
sed -i 's|zammad/zammad:7.2|zammad/zammad:7.3|' ~/.config/containers/systemd/zammad-*.container
systemctl --user daemon-reload
systemctl --user restart zammad-pod
To move Elasticsearch to a newer version, change its tag in zammad-search.container to the one in Zammad's own Docker Compose file, then daemon-reload and restart the pod. We tested the update within 7.2 only.
Troubleshooting
curl prints 000 or 502 long after the start. The setup job has not finished, or failed. journalctl --user -u zammad-init shows how far it got. On the first start it takes several minutes.
Login fails with "CSRF token verification failed". NGINX_SERVER_SCHEME=https is missing from ~/zammad.env. Add it, and restart the pod with systemctl --user restart zammad-pod.
Zammad logs every visitor with the server's own address. RAILS_TRUSTED_PROXIES is missing the server's IPv4 address. Fix it in ~/zammad.env, and restart the pod. The log is podman logs zammad-railsserver.
No mail is fetched. Channels > Email shows the error of the last fetch next to the account. Check the password, and that IMAP is turned on for the mailbox at your provider.
Customers get no automatic reply. podman logs zammad-scheduler says "no email address is set for group 'Users'". Under Manage > Groups, open Users, choose the support address as Sending Email Address, and Submit. It happens when the mailbox was set up with Do not change email address, or through Zammad's API.
Running without Elasticsearch. On a server with 4 GB, leave it out. As zammad:
cd ~/.config/containers/systemd
sed -i 's/ zammad-search.service//' zammad-init.container
rm zammad-search.container
echo ELASTICSEARCH_ENABLED=false >> ~/zammad.env
systemctl --user daemon-reload
systemctl --user stop zammad-pod
systemctl --user start zammad-pod
Zammad then searches its database instead. podman volume rm zammad-search frees the disk space of the old index, and the restore in step 9 then leaves zammad-search out.