Workload · Running a support helpdesk

Zammad self hosted: your own helpdesk on your own server

Zammad self hosted means one server running Zammad's own Docker Compose files, which bring up the helpdesk plus a database, a search engine and two caches. Plan on more memory than the 4 GB the Docker guide names as its floor: Zammad's hardware guidance asks for 6 GB for Zammad with its PostgreSQL database, and another 4 GB when the search engine lives on the same machine.

The short version
  1. One server runs the whole Zammad stack
  2. Memory guidance: 6 GB, plus 4 GB for Elasticsearch
  3. Your administrator exists before the public does
  4. Built-in nightly backup, copied off by you
  5. Open source under the GNU AGPLv3

Will one server carry your helpdesk?

Yes, if it has the memory. Here is what Zammad says, and where:

What you are planning forWhat Zammad saysWhere it says it
Just getting the containers running4 GB of RAM or moreInstall with Docker page
The smallest sensible install, Zammad plus PostgreSQL2 CPU cores and 6 GB RAM; Elasticsearch on the same box adds 4 GBHardware page, minimum setup
A team of up to 40 agents6 CPU cores and 6 GB RAM; Elasticsearch on the same box adds 6 GBHardware page, example setup

Counting the search engine, the minimum totals 10 GB and the 40-agent setup needs 12 GB in all; that sum is ours. No disk figure is given.

Picking the server that fits

A whole dedicated server suits this job: Elasticsearch needs the host kernel's vm.max_map_count turned up, and on your own machine you are root. All its memory is yours too.

  • A busy team: one of the two-processor Xeon servers, with 32 GB or more chosen on the order page.
  • A small team: an everyday single-processor server or a cartridge with at least 16 GB. Skip the X-Gene ARM cartridge, whose installer offers nothing newer than Ubuntu 18.04; ARM in general is fine, as the zammad image is also built for linux/arm64.
  • Want a screen and power button of your own for when SSH stops answering? Rack servers and blades include one; of the cartridges, the m710x and the m510 do too, the rest only power on and off. See enterprise servers.
  • A ready server is handed over within 30 minutes of approval, with no contract, and the port is unmetered.

What you are installing

One docker-compose.yml in Zammad's compose repository defines it all; Zammad's own parts share one image, each with its own command.

  • zammad-nginx answers on 8080 and forwards pages plus the /ws and /cable connections to the services behind it.
  • zammad-railsserver is the application, helped by zammad-websocket, zammad-scheduler and zammad-init.
  • zammad-postgresql stores tickets; zammad-elasticsearch powers search (1 GB heap by default); zammad-redis and zammad-memcached are the caches; zammad-backup makes the nightly backup.

The README asks for Docker Compose 2.23.1 or later and rules out Docker Swarm.

Up and running in an evening

Left alone, the compose file publishes 8080 on every address, IPv4 (0.0.0.0) and IPv6 ([::]). ufw never sees Docker's published IPv4 traffic, and misses IPv6 too if you enabled IPv6 networking inside Docker itself. Whoever opens a fresh Zammad first creates its administrator, so the steps keep 8080 on 127.0.0.1 through NGINX_EXPOSE_PORT, a trick of ours. Docker Engine 28 or later keeps such a port truly local, hence the version check.

  1. Install Ubuntu 24.04, then Docker Engine plus Compose.
  2. Become root with sudo -i and paste the block. It checks Docker, refuses if the folder exists (a rerun would replace a database password in use), enables the firewall with SSH allowed, raises the kernel setting now and at boot, and writes a private .env holding a fresh database password.
  3. Define a helper so later commands work from any folder: Z=/opt/zammad-docker-compose; zc() { docker compose -f "$Z/docker-compose.yml" "$@"; }. A new shell forgets zc, so each section below starts by defining it again. Watch zc ps until zammad-railsserver is healthy; the first start is slow while zammad-init seeds and indexes.
set -o pipefail;
v=$(docker version --format '{{.Server.Version}}') && [ "${v%%.*}" -ge 28 ] &&
c=$(docker compose version --short) &&
[ "$(printf '%s\n' "$c" 2.23.1 | sort -V | head -n1)" = 2.23.1 ] &&
zone=$(timedatectl show -p Timezone --value) && [ -n "$zone" ] &&
[ ! -d /opt/zammad-docker-compose ] &&
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
ufw allow OpenSSH && ufw --force enable &&
printf 'vm.max_map_count=262144\n' > /etc/sysctl.d/90-zammad-es.conf &&
sysctl -w vm.max_map_count=262144 &&
git clone https://github.com/zammad/zammad-docker-compose.git /opt/zammad-docker-compose &&
cd /opt/zammad-docker-compose &&
dbpw=$(openssl rand -hex 24) && [ -n "$dbpw" ] &&
install -m 600 /dev/null .env &&
printf 'POSTGRES_PASS=%s\nNGINX_EXPOSE_PORT=127.0.0.1:8080\nTZ=%s\n' "$dbpw" "$zone" > .env &&
docker compose config | grep 'host_ip: 127.0.0.1' > /dev/null &&
docker compose up -d && echo 'stack is starting' || echo 'stopped before starting anything'

Zammad's Docker page only shows sysctl -w, forgotten at reboot; the /etc/sysctl.d file is ours. Per .env.dist, the PostgreSQL superuser shares POSTGRES_PASS unless you give POSTGRES_SUPERUSER_PASS its own value, and root can see .env values through docker inspect. TZ gets the server's own zone: the backup clock follows TZ (default Europe/Berlin), the copy job below follows the server clock, and they must match.

Make yourself the administrator first

Zammad's setup wizard creates the administrator in step one, for whoever arrives first. Make that you, privately: on your computer run ssh -N -L 9080:127.0.0.1:8080 you@server.example.com and open http://localhost:9080.

  • Your password needs at least 10 characters: two capitals, two small letters and one digit.
  • The guessed instance address will be wrong over the tunnel; you fix it once your domain works.
  • Notifications start on sendmail. Switch to SMTP, or keep sendmail and change it later, which the docs suggest if SMTP fails.
  • Leave the first email channel for later.

Putting it on your own address

Zammad should never face the internet directly, its webserver page says, so nginx goes in front with a free certificate. ZAMMAD_HTTP_TYPE and ZAMMAD_FQDN are listed in .env.dist as needed for HTTPS behind a proxy, and NGINX_SERVER_SCHEME=https keeps the login working: skip it and you get a CSRF token error, because Zammad won't set its secure session cookie. Browsers also hold live connections on /ws and /cable, which need the Upgrade header passed through.

Our nginx file is a simplified take on contrib/nginx/zammad_ssl.conf from the zammad repository: all traffic goes to 127.0.0.1:8080 for zammad-nginx to sort, minus asset caching, log paths and ciphers; certbot writes the HTTPS part. Taking nginx and certbot from the Ubuntu 24.04 archive is our pick; Zammad's Docker page suggests scenario files for a Cloudflare tunnel or Nginx Proxy Manager. Point DNS here, swap in your name for support.example.com, and paste as root.

yml=/opt/zammad-docker-compose/docker-compose.yml &&
! grep -q '^NGINX_SERVER_SCHEME=' /opt/zammad-docker-compose/.env &&
printf 'ZAMMAD_FQDN=support.example.com\nZAMMAD_HTTP_TYPE=https\nNGINX_SERVER_SCHEME=https\n' >> /opt/zammad-docker-compose/.env &&
docker compose -f "$yml" up -d && apt install -y nginx certbot python3-certbot-nginx &&
cat > /etc/nginx/sites-available/zammad.conf <<'EOF' &&
server {
  listen 80;
  listen [::]:80;
  server_name support.example.com;
  client_max_body_size 50M;
  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 180;
  }
  location ~ ^/(ws|cable) {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400;
  }
}
EOF
ln -s /etc/nginx/sites-available/zammad.conf /etc/nginx/sites-enabled/zammad.conf &&
nginx -t && systemctl reload nginx &&
ufw allow 80/tcp && ufw allow 443/tcp &&
certbot --nginx --redirect -d support.example.com

The first time, certbot wants an e-mail address and your agreement to its terms; --redirect sends plain HTTP visits to HTTPS. From now on sign in through your domain. Under Settings, System, Base, make sure FQDN and HTTP type show your domain and https, and fix them before connecting any mailbox: the admin docs warn later changes alter callback URLs and can stop you logging in.

Connecting your support mailbox safely

Mailboxes go under Channels, then Email. Zammad guesses the mail server from the address, and an Experts dialog lets you type it. Two defaults surprise people:

  1. Zammad removes each message after importing it. Choose yes for Keep messages on server to leave them; Zammad then marks them read and fetches only unread ones.
  2. Every imported email gets an auto-reply, old ones too. Archive mode brings older messages in with their original dates and fires nothing.

Incoming is IMAP or POP3 over SSL or STARTTLS (993 for IMAP, typically); outgoing is SMTP (ports 587 or 465) or, on self-hosted installs, a mail program on the server itself. Hosting the mail too? See our mailcow workload.

Nightly backups, and getting them off the box

The stack backs itself up: contrib/docker/backup.sh from the zammad repository dumps PostgreSQL and archives /opt/zammad/storage, both landing in the zammad-backup volume, nightly at 03:00 in the containers' TZ by default. Each run first clears out archives older than 10 days (HOLD_DAYS). Thanks to pipefail a failing dump stops the run with an error, yet a cut-off file can stay behind, so glance at the sizes. Trigger one with docker compose -f /opt/zammad-docker-compose/docker-compose.yml run --rm -e BACKUP_ONCE=true zammad-backup. The copies share disks with Zammad and leave out .env. You manage the server; we keep the hardware and the network running, so getting copies away is your step. Our cron file fires at 05:15 server time, after that night's backup since both use the server's zone, sends nothing without a fresh dump (under three hours), logs everything and ships with rsync minus its delete option; the block ends by installing root's key on the second computer. Prune there only once a new dump has landed, like [ -n "$(find ~/zammad -name '*db.psql.gz' -newermt '1 day ago')" ] && find ~/zammad -name '*.gz' -mtime +30 -delete, with rsync on both ends.

install -d -m 700 /root/zammad-copies /var/log/zammad-copies &&
{ [ -e /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519; } &&
printf '%s\n' "15 5 * * * root umask 077; { v=\$(docker volume inspect -f '{{.Mountpoint}}' zammad-docker-compose_zammad-backup) && find \"\$v\" -maxdepth 1 -name '*_zammad_db.psql.gz' -mmin -180 | grep -q . && tar -czf /root/zammad-copies/settings.tar.gz -C /opt zammad-docker-compose/.env zammad-docker-compose/docker-compose.yml && rsync -a \"\$v\"/ /root/zammad-copies/settings.tar.gz copies@offsite.example.com:zammad/; } > /var/log/zammad-copies/rsync.log 2>&1" > /etc/cron.d/zammad-copies &&
cat /etc/cron.d/zammad-copies &&
ssh-copy-id copies@offsite.example.com

Bringing a backup back, as root: start by defining Z=/opt/zammad-docker-compose; zc() { docker compose -f "$Z/docker-compose.yml" "$@"; } again, and set B=/var/lib/docker/volumes/zammad-docker-compose_zammad-backup/_data (docker volume inspect confirms it as Mountpoint).

  1. Save where you are with zc run --rm -e BACKUP_ONCE=true zammad-backup; if the backup command errors, stop here. Then ls -l "$B" and check the newest db file has a sensible size. That pair is your undo.
  2. Shut everything down with zc down. Never add --volumes, which erases the backups too.
  3. Copy the night you want: mkdir "$B/restore" && cp "$B"/STAMP_zammad_* "$B/restore/", its timestamp in place of STAMP. An existing restore folder makes mkdir stop.
  4. Attachments kept on the file system? The docs say to clear /opt/zammad/storage first, as restores never remove files. That folder is the zammad-storage volume: find /var/lib/docker/volumes/zammad-docker-compose_zammad-storage/_data -mindepth 1 -delete.
  5. Start with zc up -d. zammad-backup refills the database and files as the rest waits, then renames the folder.
  6. Rebuild search with the zammad:searchindex:rebuild rake task via bundle exec in zammad-railsserver; the exact line is on the Docker backup page.

Not right? Run steps 2 to 6 again using your step-1 pair. Moving to a new server, working as root: put Docker Engine 28+ and Compose 2.23.1+ on it, run the setup block's firewall and kernel lines, and git clone the compose repository into /opt/zammad-docker-compose. The second computer has never seen this machine, so make a root key if there is none (ssh-keygen -f /root/.ssh/id_ed25519 -t ed25519 -N '') and run ssh-copy-id copies@offsite.example.com. Fetch settings.tar.gz, unpack it with tar -xzpf settings.tar.gz -C /opt, and give the server the TZ named in that .env. Start once with docker compose -f /opt/zammad-docker-compose/docker-compose.yml up -d; when zammad-railsserver is healthy, stop with the same command ending in down, never adding --volumes. Then B=/var/lib/docker/volumes/zammad-docker-compose_zammad-backup/_data; mkdir "$B/restore" && rsync -a 'copies@offsite.example.com:zammad/STAMP_zammad_*' "$B/restore/", STAMP being the night's timestamp, start again and rebuild search. Your restored .env holds NGINX_SERVER_SCHEME, so the HTTPS block stops at its check: point DNS at the new server, run its lines from apt install on by hand, then paste the copy block again.

Staying current, and what the licence asks

To update, define Z=/opt/zammad-docker-compose; zc() { docker compose -f "$Z/docker-compose.yml" "$@"; } in your root shell, back up, read the compose repository's release notes for breaking changes, then cd /opt/zammad-docker-compose && git pull, zc pull, zc up -d. Zammad says not to jump major versions; its own example takes 2.4 through 3.0, 4.0 and 5.0 to 5.1. Several majors behind? Master carries the newest Elasticsearch and PostgreSQL, which an old Zammad may not support. Move one major at a time: check out the compose repository's release matching the next major (Releases page, read its notes), or keep your files and set VERSION in .env, where a floating tag like VERSION=6 tracks the 6.x releases. Pull, start, check, go on. Once you reach the major you run today, git checkout master in /opt/zammad-docker-compose so git pull works again, and take any VERSION line back out of .env, or the image stays held at that tag. Git ignores .env, so pulling never overwrites it.

Zammad's licence is the GNU AGPLv3, according to the LICENSE file and README in both repositories, and the Zammad Foundation owns the code. This is how we read it, not legal advice: using it unchanged for your own support desk asks nothing of you; giving anyone a copy, modified or as released, means giving them the source as well, on AGPL terms; and when people use a version you changed over a network, you owe them its source.

Questions people ask

Do I really need Elasticsearch?
Zammad calls it optional yet strongly recommended. Searches run slower without it, can do far less and skip attachment contents. The README shows ELASTICSEARCH_ENABLED=false with the disable-elasticsearch-service scenario file.
Why does my login keep failing after I added HTTPS?
Zammad most likely thinks the browser is on plain HTTP. Put NGINX_SERVER_SCHEME=https in your .env, then docker compose -f /opt/zammad-docker-compose/docker-compose.yml up -d, so Zammad sets its secure session cookie, then confirm Settings, System, Base shows https.
How do I move Zammad to another server later?
Follow the moving steps at the end of the backup section: the same setup, your saved settings and backup pair from the second computer, one start and stop, the restore, then HTTPS and the copy job again.

Give your support team its own server

Choose a machine with room for Zammad and its search engine, and have it handed over within 30 minutes of approval when it is a ready server.

Get a server for your helpdesk

Read next