Use case · Helpdesk

Zammad self hosted on a dedicated server: the Compose stack, start to backups

A self-hosted Zammad runs as the official Docker Compose stack from the zammad/zammad-docker-compose repository: PostgreSQL, Elasticsearch, Redis, memcached and Zammad's own nginx, Rails, websocket and scheduler containers on one host. Zammad's Docker page wants a minimum of 4 GB of RAM for the containers plus the vm.max_map_count sysctl for Elasticsearch; the hardware page puts a minimal install with PostgreSQL at 2 cores and 6 GB, plus 4 GB when Elasticsearch shares the server.

See the machines

What the Compose stack runs

docker-compose.yml in the zammad-docker-compose repository defines the stack; every Zammad service runs the ghcr.io/zammad/zammad image.

ServiceJobDetail from docker-compose.yml
zammad-nginxWeb front endPort 8080 (NGINX_EXPOSE_PORT); proxies /ws and /cable inside
zammad-railsserverApplicationRails on port 3000
zammad-websocket, zammad-schedulerRealtime, background jobsSame image, own commands
zammad-initMigrations, settingsRuns at every start
zammad-postgresqlDatabaseUnprivileged zammad role created at first init
zammad-elasticsearchSearch indexsingle-node, 1 GB heap by default
zammad-redis, zammad-memcachedRealtime messaging, cachememcached -m 256M
zammad-backupNightly backuppg_dump plus a tar of /opt/zammad/storage

Named volumes hold the state: postgresql-data, elasticsearch-data, redis-data, zammad-storage and zammad-backup. The README requires Docker Compose 2.23.1 or newer and does not support Docker Swarm.

Sizing the machine

Zammad's three memory figures carry different scopes.

Source pageFigureScope
Install with Docker4 GB RAM minimumTo run the containers
Hardware: minimum setup2 CPU cores, 6 GB RAM, +4 GB for Elasticsearch on the same serverMinimal deployment, PostgreSQL database
Hardware: example setup6 CPU cores, 6 GB RAM, +6 GB for Elasticsearch on the same serverA starting point for up to 40 agents

No disk figure is given. On one box with Elasticsearch, the minimum adds up to 10 GB, the 40-agent example to 12 GB (our arithmetic). A whole machine fits this stack for a concrete reason: Elasticsearch needs vm.max_map_count raised on the host kernel, a setting only root on the host can change, and on a dedicated server that root is you.

  • Small team: the Xeon E3-1585L v5 cartridge (the m710x line, 16 to 64 GB, NVMe), which has its own out-of-band console. The X-Gene ARM cartridge is out because its only images are Ubuntu 16.04 and 18.04. That is the OS list, not ARM itself; see the FAQ.
  • Dozens of agents: a single- or dual-socket rack server with 32 GB or more. Each racked single-, dual- or quad-socket server carries a management controller (iLO, iDRAC or BMC) with a remote console.
  • Ready servers are live roughly 30 minutes from payment approval; built-to-order lines take 4 to 24 hours. Ports are unmetered.

Distributions are listed on Linux dedicated servers; a separate database box is covered in our PostgreSQL notes.

First start, bound to 127.0.0.1

docker-compose.yml maps 8080 with no address, publishing it on every host address, 0.0.0.0 and [::]; published ports skip ufw's rules on IPv4, and on IPv6 when Docker's own IPv6 networking is on. Whoever reaches it first meets the setup wizard, so the block sets NGINX_EXPOSE_PORT=127.0.0.1:8080 (our use of the variable). That is truly local on Docker Engine 28+, so the block checks the engine, Compose and the rendered host_ip first.

Paste it in a root shell on Ubuntu 24.04 once Docker Engine and Compose are installed. It enables ufw (SSH allowed), persists the sysctl in /etc/sysctl.d, and writes a mode-600 .env with a random database password. It stops if /opt/zammad-docker-compose exists, so a repeat paste cannot swap the password of an initialised database.

set -o pipefail;
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
ufw allow OpenSSH && ufw --force enable &&
echo 'vm.max_map_count=262144' > /etc/sysctl.d/90-zammad.conf &&
sysctl -w vm.max_map_count=262144 &&
v=$(docker version --format '{{.Server.Version}}') &&
[ "${v%%.*}" -ge 28 ] &&
c=$(docker compose version --short) &&
[ "$(printf '%s\n' 2.23.1 "$c" | sort -V | head -n1)" = 2.23.1 ] &&
tz=$(timedatectl show -p Timezone --value) && [ -n "$tz" ] &&
[ ! -e /opt/zammad-docker-compose ] &&
git clone https://github.com/zammad/zammad-docker-compose.git /opt/zammad-docker-compose &&
cd /opt/zammad-docker-compose &&
pw=$(openssl rand -hex 24) && [ -n "$pw" ] &&
install -m 600 /dev/null .env &&
printf 'POSTGRES_PASS=%s\nNGINX_EXPOSE_PORT=127.0.0.1:8080\nTZ=%s\n' "$pw" "$tz" > .env &&
docker compose config | grep 'host_ip: 127.0.0.1' > /dev/null &&
docker compose up -d && echo STARTED || echo 'STOPPED: a check failed'

POSTGRES_PASS doubles as the PostgreSQL superuser password unless POSTGRES_SUPERUSER_PASS is set (.env.dist); root can read .env values with docker inspect. TZ copies the host's zone (our choice), so BACKUP_TIME, read in the containers' zone (default Europe/Berlin), matches the host clock cron uses.

On first start zammad-init migrates, seeds and indexes (bin/docker-entrypoint in the zammad repository). The helper y=/opt/zammad-docker-compose; zd() { docker compose -f "$y/docker-compose.yml" "$@"; } runs compose from any directory; a new shell forgets it, so later sections define it again. zd ps shows when zammad-railsserver turns healthy.

Creating the administrator over a tunnel

A new Zammad's first visitor meets the Getting Started wizard, which creates the administrator. Do it before any proxy exists, over an SSH tunnel that uses your login: ssh -N -L 28080:127.0.0.1:8080 you@your-server, then http://localhost:28080.

  1. Administrator: the default policy wants 10 or more characters, 2 upper-case and 2 lower-case letters and a digit.
  2. Organisation: the detected instance address will read localhost through the tunnel; the next section corrects it.
  3. Notification channel: sendmail by default. If SMTP fails here, the docs suggest picking sendmail and fixing it later.
  4. First email channel: skip it for now; the warnings below explain why.

Domain, HTTPS and websockets

Zammad's webserver page says never to expose Zammad directly, so a TLS proxy goes in front once the administrator exists. The env template marks ZAMMAD_FQDN and ZAMMAD_HTTP_TYPE=https as required for HTTPS behind a proxy. NGINX_SERVER_SCHEME=https is needed when TLS ends in front of zammad-nginx; without it Zammad does not set its Secure session cookie and login fails with a CSRF token error. Browsers hold websockets on /ws and /cable, so the proxy must pass the Upgrade header there.

The server block is ours, cut down from contrib/nginx/zammad_ssl.conf in the zammad repository: both upstreams go to 127.0.0.1:8080, where zammad-nginx splits traffic, without the asset, logging and cipher directives; certbot adds 443. Host nginx and certbot from Ubuntu 24.04's packages are our choice; Zammad's Docker page offers Nginx Proxy Manager or Cloudflare tunnel scenarios. Point A and AAAA records here and replace help.example.com throughout.

f=/opt/zammad-docker-compose/docker-compose.yml &&
! grep -q '^ZAMMAD_FQDN=' /opt/zammad-docker-compose/.env &&
printf 'ZAMMAD_FQDN=help.example.com\nZAMMAD_HTTP_TYPE=https\nNGINX_SERVER_SCHEME=https\n' >> /opt/zammad-docker-compose/.env &&
docker compose -f "$f" up -d && apt install -y nginx certbot python3-certbot-nginx &&
cat > /etc/nginx/sites-available/zammad <<'EOF' &&
server {
  listen 80;
  listen [::]:80;
  server_name help.example.com;
  client_max_body_size 50M;
  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;
  }
  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;
  }
}
EOF
ln -s /etc/nginx/sites-available/zammad /etc/nginx/sites-enabled/ &&
nginx -t && systemctl reload nginx && ufw allow 80,443/tcp &&
certbot --nginx --redirect -d help.example.com

Certbot asks for an e-mail address and its terms once; --redirect forces HTTPS. Then sign in at your domain and open Settings, System, Base. If FQDN and HTTP type still show the tunnel address, correct them now, before adding channels: the admin docs warn that changing them alters channel callback URLs and can lock you out.

Email channels: IMAP in, SMTP out

Accounts go under Channels, Email; Zammad guesses server settings from the address, or the Experts dialog takes them. Two admin-docs warnings matter before a live mailbox is connected.

  • Zammad deletes imported messages by default; Keep messages on server set to yes leaves them, marked read, and only unread mail is imported.
  • It auto-replies to every imported message, old ones too; archive mode imports older mail with its dates and no automatic actions.
  • Inbound is IMAP (usually 993) or POP3 with SSL or STARTTLS; outbound is SMTP (usually 587 or 465) or a local MTA.

Notifications have their own Email Notification panel. For the mail server itself, see the mailcow notes.

Backups and a restore you can roll back

The zammad-backup service (contrib/docker/backup.sh in the zammad repository) runs at BACKUP_TIME, 03:00 by default, writing a pg_dump and a tar of /opt/zammad/storage into the zammad-backup volume. Each run first deletes archives older than HOLD_DAYS (10). With pipefail set, a failed dump ends the run with an error, but a cut-off .psql.gz can remain, so check sizes. The set omits .env and the compose files and sits on the data disks. Our block pushes both elsewhere: a root key, then a cron file at 04:45 host time, after the 03:00 backup since both clocks share the host's zone. It ships only if a dump under three hours old exists, logs the chain, and uses rsync without delete. Prune on the far side only after a fresh dump arrived, e.g. find ~/zammad -name '*_zammad_db.psql.gz' -mtime -1 | grep -q . && find ~/zammad -name '*_zammad_*.gz' -mtime +30 -delete. Both machines need rsync.

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

To restore on the same machine, in a root shell where you first define y=/opt/zammad-docker-compose; zd() { docker compose -f "$y/docker-compose.yml" "$@"; }:

  1. Take an on-demand backup of the current state, zd run --rm --env BACKUP_ONCE=true zammad-backup; stop here on any error. Then zd run --rm --no-deps zammad-backup ls -l /var/tmp/zammad should show a new db file that is not tiny. That set is your way back.
  2. zd down, never with --volumes, which deletes the backups too.
  3. Stage the night you want: zd run --rm --no-deps zammad-backup sh -c "mkdir /var/tmp/zammad/restore && cp /var/tmp/zammad/STAMP_zammad_* /var/tmp/zammad/restore/", STAMP being its timestamp. The mkdir refuses if a restore is already staged.
  4. Only with attachments on the file system: the docs say to empty /opt/zammad/storage first, as the restore only adds files. The backup service mounts zammad-storage there, so zd run --rm --no-deps zammad-backup sh -c 'rm -rf /opt/zammad/storage/*'.
  5. zd up -d. The backup service drops and restores the database and unpacks the files while the others wait, then renames the restore folder.
  6. zd run --rm zammad-railsserver bundle exec rake zammad:searchindex:rebuild rebuilds the index.

Wrong result? Repeat steps 2 to 6 with the step-1 set. On a new machine the install guard refuses, so: set up Docker Engine 28+ plus Compose 2.23.1 or newer, and the block's ufw and sysctl lines; clone the repository to /opt/zammad-docker-compose; paste the off-site block now, which makes root's key and ends with ssh-copy-id to the backup host; fetch config.tar.gz and tar -xzpf config.tar.gz -C /opt over the clone; set the host zone to that .env's TZ; define zd; zd up -d once so PostgreSQL initialises with your password, and zd down (never --volumes) when zammad-railsserver is healthy. Its backup volume starts out empty: rsync that night's two STAMP_zammad_* files from the backup host into /root/restore-set, then zd run --rm --no-deps -v /root/restore-set:/src:ro zammad-backup sh -c 'mkdir /var/tmp/zammad/restore && cp /src/*_zammad_* /var/tmp/zammad/restore/', zd up -d, rebuild the index. The HTTPS block's guard trips on the restored .env, so once DNS names the new machine, paste its commands by hand from apt install, then re-run the off-site block.

Updates and the licence

Updating, in a root shell with y=/opt/zammad-docker-compose; zd() { docker compose -f "$y/docker-compose.yml" "$@"; } defined, after an on-demand backup: git -C /opt/zammad-docker-compose pull, zd pull, zd up -d. Read the compose release notes first for breaking changes. Never skip a major Zammad version; the docs' example path is 2.4, 3.0, 4.0, 5.0, 5.1. Several majors behind, do not pull master at once: it brings the newest stack (Elasticsearch 9.x, PostgreSQL 17), which an older major may not support. Step one major at a time, either switching the clone to the release tag that ships the next major (see the Releases page and its notes) or keeping your files and setting VERSION in .env (.env.dist shows floating tags such as VERSION=6). Start, check, repeat; at your current major, git -C /opt/zammad-docker-compose checkout master makes pulls work again. If a migrated docker-compose.yml blocks the pull and the stack already runs the current major, git -C /opt/zammad-docker-compose checkout docker-compose.yml resets it, safe for settings since this page's, the 127.0.0.1 bind included, live in .env; an older stack steps through release tags as above instead. On the current major, delete any VERSION line from .env so the compose file's default applies again.

Zammad is licensed under the GNU AGPLv3, per the LICENSE file and README of both repositories, and the code is owned by the Zammad Foundation. Our general reading, not legal advice: running it unmodified for your own helpdesk asks nothing of you; if you distribute copies, modified or not, you pass on the source under the same licence; and a modified Zammad that others reach across a network obliges you to make its changed source available to them.

Questions

Does Zammad run on ARM servers?

Yes: Zammad publishes its image for linux/arm64 as well as amd64. Our X-Gene cartridge is the exception, because its images stop at Ubuntu 18.04.

Why does Zammad ask for vm.max_map_count?

Zammad's Docker install page has you raise it to 262144 with sysctl -w before starting the stack, to run Elasticsearch properly. That lasts until reboot, so the setup block also writes it under /etc/sysctl.d, our addition.

Why do logins fail with a CSRF error behind my proxy?

Zammad did not learn that the browser is on HTTPS. With TLS ending at a proxy in front of zammad-nginx, set NGINX_SERVER_SCHEME=https in .env and recreate the stack with up -d, as in the update section; without it Zammad does not write its Secure session cookie, per the env template.

A machine for your helpdesk

Pick a line with the memory Zammad and Elasticsearch need, then configure disks, city and operating system.

See the machines

Related on Server Room

Other use cases