# Zammad

Web-based helpdesk and customer support system.

- Replaces: [Zendesk](https://openlogs.fyi/alternatives/zendesk)
- Summary: Paid helpdesk and customer support services, such as Zendesk.
- Stack: Ruby (language), PostgreSQL (database), Redis (database)
- Deployment: docker_compose
- License: AGPL-3.0 (osi) [source](https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/LICENSE)
- Business model: unknown. The README at the pinned commit does not state a business model, so it stays unknown. ([source](https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/README.md), read 2026-10-22)
- Difficulty: hard. 10 services (4 database or broker: elasticsearch, memcached, postgres, redis), 5 volume mounts, 68 env values to set, no elevated host access. Heuristic score 9 of 11: hard.
- Requirements: not stated
- Repository: [zammad/zammad](https://github.com/zammad/zammad)

## Install

1. Docker with the Compose plugin ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml))

   ```text
   A host with Docker and the Docker Compose plugin. This entry runs from a compose file.
   ```

2. Save the compose file as docker-compose.yml ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml))

   ```yaml
   ---
   x-shared:
     zammad-service: &zammad-service
       environment: &zammad-environment
         MEMCACHE_SERVERS: ${MEMCACHE_SERVERS:-zammad-memcached:11211}
         POSTGRESQL_DB: ${POSTGRES_DB:-zammad_production}
         POSTGRESQL_HOST: ${POSTGRES_HOST:-zammad-postgresql}
         POSTGRESQL_USER: ${POSTGRES_USER:-zammad}
         POSTGRESQL_PASS: ${POSTGRES_PASS:-zammad}
         POSTGRESQL_PORT: ${POSTGRES_PORT:-5432}
         POSTGRESQL_OPTIONS: ${POSTGRESQL_OPTIONS:-?pool=50}
         # The bundled zammad-postgresql service creates the database up front, and its
         #   application role is deliberately not allowed to create databases. Set this to
         #   'true' if you point Zammad at an external server and want it to create the
         #   database itself - the configured role then needs the CREATEDB attribute.
         POSTGRESQL_DB_CREATE: ${POSTGRESQL_DB_CREATE:-false}
   
         # Redis standalone
         REDIS_URL: ${REDIS_URL:-redis://zammad-redis:6379}
         # Redis sentinel
         REDIS_SENTINELS:
         REDIS_SENTINEL_NAME:
         REDIS_USERNAME:
         REDIS_PASSWORD:
         REDIS_SENTINEL_USERNAME:
         REDIS_SENTINEL_PASSWORD:
   
         S3_URL:
         # Backup settings
         BACKUP_DIR: "${BACKUP_DIR:-/var/tmp/zammad}"
         BACKUP_TIME: "${BACKUP_TIME:-03:00}"
         BACKUP_ON_START: "${BACKUP_ON_START:-false}"
         HOLD_DAYS: "${HOLD_DAYS:-10}"
         TZ: "${TZ:-Europe/Berlin}"
         # Allow passing in these variables via .env:
         AUTOWIZARD_JSON:
         AUTOWIZARD_RELATIVE_PATH:
         ELASTICSEARCH_ENABLED:
         ELASTICSEARCH_SCHEMA:
         ELASTICSEARCH_HOST:
         ELASTICSEARCH_PORT:
         ELASTICSEARCH_USER:
         ELASTICSEARCH_PASS:
         ELASTICSEARCH_NAMESPACE:
         ELASTICSEARCH_REINDEX:
         NGINX_PORT:
         NGINX_CLIENT_MAX_BODY_SIZE:
         NGINX_SERVER_NAME:
         NGINX_SERVER_SCHEME:
         RAILS_TRUSTED_PROXIES:
         ZAMMAD_HTTP_TYPE:
         ZAMMAD_FQDN:
         ZAMMAD_WEB_CONCURRENCY:
         ZAMMAD_MANAGE_SESSIONS_JOBS_WORKERS:
         ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS:
         ZAMMAD_PROCESS_SCHEDULED_JOBS_WORKERS:
         ZAMMAD_PROCESS_DELAYED_JOBS_WORKERS:
         ZAMMAD_PROCESS_DELAYED_JOBS_WORKER_THREADS:
         ZAMMAD_PROCESS_DELAYED_AI_JOBS_WORKERS:
         ZAMMAD_PROCESS_DELAYED_AI_JOBS_WORKER_THREADS:
         ZAMMAD_PROCESS_DELAYED_COMMUNICATION_INBOUND_JOBS_WORKERS:
         ZAMMAD_PROCESS_DELAYED_COMMUNICATION_INBOUND_JOBS_WORKER_THREADS:
         ZAMMAD_OTRS_IMPORT_READ_TIMEOUT:
         ZAMMAD_OTRS_IMPORT_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_OPEN_TIMEOUT:
         ZAMMAD_HTTP_READ_TIMEOUT:
         ZAMMAD_HTTP_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_AI_READ_TIMEOUT:
         ZAMMAD_HTTP_AI_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_ELASTICSEARCH_READ_TIMEOUT:
         ZAMMAD_HTTP_ELASTICSEARCH_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_ELASTICSEARCH_REINDEX_READ_TIMEOUT:
         ZAMMAD_HTTP_ELASTICSEARCH_REINDEX_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_IMPORT_ATTACHMENT_READ_TIMEOUT:
         ZAMMAD_HTTP_IMPORT_ATTACHMENT_TOTAL_TIMEOUT:
         ZAMMAD_HTTP_WEBHOOK_READ_TIMEOUT:
         ZAMMAD_HTTP_WEBHOOK_TOTAL_TIMEOUT:
   
         # Allow disabling individual background services in the scheduler container.
         ZAMMAD_PROCESS_SESSIONS_JOBS_DISABLE:
         ZAMMAD_MANAGE_SESSIONS_JOBS_DISABLE:
         ZAMMAD_PROCESS_SCHEDULED_JOBS_DISABLE:
         ZAMMAD_PROCESS_DELAYED_JOBS_DISABLE:
         ZAMMAD_PROCESS_DELAYED_AI_JOBS_DISABLE:
         ZAMMAD_PROCESS_DELAYED_COMMUNICATION_INBOUND_JOBS_DISABLE:
   
         ZAMMAD_GRAPHQL_INTROSPECTION:
         ZAMMAD_AI_API_URL:
         ZAMMAD_AI_TOKEN:
         ZAMMAD_UI_BULK_BACKGROUND_UPDATE_THRESHOLD:
         ZAMMAD_SETTING_TTL:
         ZAMMAD_SAFE_MODE:
         ZAMMAD_WEBSOCKET_SESSION_STORE_FORCE_FS_BACKEND:
         ZAMMAD_RAILSSERVER_PORT:
   
         # ZAMMAD_SESSION_JOBS_CONCURRENT is deprecated, please use ZAMMAD_PROCESS_SESSIONS_JOBS_WORKERS instead.
         ZAMMAD_SESSION_JOBS_CONCURRENT:
         # Variables used by ngingx-proxy container for reverse proxy creations
         # for docs refer to https://github.com/nginx-proxy/nginx-proxy
         VIRTUAL_HOST:
         VIRTUAL_PORT:
         # Variables used by acme-companion for retrieval of LetsEncrypt certificate
         # for docs refer to https://github.com/nginx-proxy/acme-companion
         LETSENCRYPT_HOST:
         LETSENCRYPT_EMAIL:
   
       image: ${IMAGE_REPO:-ghcr.io/zammad/zammad}:${VERSION:-7.2.2-0012}
       init: true
       restart: ${RESTART:-always}
       volumes:
         - zammad-backup:/var/tmp/zammad:ro # needed for waiting on restore operations
         - zammad-storage:/opt/zammad/storage
       depends_on:
         zammad-memcached:
           condition: service_healthy
         zammad-postgresql:
           condition: service_healthy
         zammad-redis:
           condition: service_healthy
   
   services:
     zammad-backup:
       <<: *zammad-service
       command: ["zammad-backup"]
       volumes:
         - zammad-backup:/var/tmp/zammad
         - zammad-storage:/opt/zammad/storage
       user: 0:0
   
     zammad-elasticsearch:
       image: elasticsearch:${ELASTICSEARCH_VERSION:-9.5.5}
       restart: ${RESTART:-always}
       volumes:
         - elasticsearch-data:/usr/share/elasticsearch/data
       environment:
         discovery.type: single-node
         xpack.security.enabled: "false"
         ES_JAVA_OPTS: ${ELASTICSEARCH_JAVA_OPTS:--Xms1g -Xmx1g}
   
     zammad-init:
       <<: *zammad-service
       command: ["zammad-init"]
       restart: on-failure
       user: 0:0
   
     zammad-memcached:
       command: memcached -m 256M
       image: memcached:${MEMCACHE_VERSION:-1.6.45-alpine}
       restart: ${RESTART:-always}
       healthcheck:
         test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
         interval: 10s
         timeout: 5s
         start_period: 10s
         retries: 5
   
     zammad-nginx:
       <<: *zammad-service
       command: ["zammad-nginx"]
       init: false
       expose:
         - "${NGINX_PORT:-8080}"
       ports:
         - "${NGINX_EXPOSE_PORT:-8080}:${NGINX_PORT:-8080}"
       depends_on:
         zammad-railsserver:
           condition: service_healthy
   
     zammad-postgresql:
       # PostgreSQL tuning: dedicated variables for the settings the Zammad documentation
       #   recommends to tune, plus POSTGRES_EXTRA_OPTS for any other setting as raw
       #   -c flags - see .env.dist. Only variables that are set are passed to the server,
       #   unset ones leave it completely untouched (including manual tuning in the data
       #   volume). Compose word-splits the string into argv like a shell, so the
       #   free-form POSTGRES_EXTRA_OPTS works without a shell wrapper.
       command: >-
         postgres
         ${POSTGRES_MAX_CONNECTIONS:+-c max_connections=${POSTGRES_MAX_CONNECTIONS}}
         ${POSTGRES_SHARED_BUFFERS:+-c shared_buffers=${POSTGRES_SHARED_BUFFERS}}
         ${POSTGRES_TEMP_BUFFERS:+-c temp_buffers=${POSTGRES_TEMP_BUFFERS}}
         ${POSTGRES_WORK_MEM:+-c work_mem=${POSTGRES_WORK_MEM}}
         ${POSTGRES_MAX_STACK_DEPTH:+-c max_stack_depth=${POSTGRES_MAX_STACK_DEPTH}}
         ${POSTGRES_EXTRA_OPTS:-}
       # Docker caps /dev/shm at 64MB - the one PostgreSQL-adjacent knob a -c flag cannot
       #   reach. Raise it together with shared_buffers/work_mem, otherwise parallel query
       #   workers can fail with "could not resize shared memory segment".
       shm_size: ${POSTGRES_SHM_SIZE:-64mb}
       environment:
         # The bootstrap role of the postgres image is always a superuser, so it is kept
         #   separate from the role Zammad connects with and only used for administration.
         #   Its password falls back to POSTGRES_PASS, so that hardening that one variable
         #   does not silently leave a superuser behind on the default password.
         POSTGRES_USER: ${POSTGRES_SUPERUSER:-postgres}
         POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASS:-${POSTGRES_PASS:-zammad}}
         # Consumed by the initdb hook below, to provision Zammad's unprivileged role
         #   and the database it owns.
         ZAMMAD_DB: ${POSTGRES_DB:-zammad_production}
         ZAMMAD_DB_USER: ${POSTGRES_USER:-zammad}
         ZAMMAD_DB_PASS: ${POSTGRES_PASS:-zammad}
       image: postgres:${POSTGRES_VERSION:-17.11-alpine}
       restart: ${RESTART:-always}
       volumes:
         - postgresql-data:/var/lib/postgresql/data
       configs:
         - source: postgresql-initdb
           target: /docker-entrypoint-initdb.d/10-create-zammad-role.sh
           mode: 0555
       healthcheck:
         # Connect over TCP as Zammad's own role rather than using pg_isready, which
         #   reports success for an unknown role or database, and would also accept the
         #   socket-only server that runs while the data directory is still initialising.
         #   Dependent services wait for this healthcheck, so it has to establish that
         #   the role and the database the initdb hook provisions exist and accept
         #   connections.
         #
         # The password is supplied so that this keeps working where host connections
         #   require authentication, for example with
         #   POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256. With the image default the
         #   loopback address is trusted, so the credentials are not verified there.
         test:
           - CMD-SHELL
           - |-
             PGPASSWORD="$${ZAMMAD_DB_PASS}" \
             psql --no-password --quiet --output /dev/null --variable ON_ERROR_STOP=1 \
                  --host 127.0.0.1 --username "$${ZAMMAD_DB_USER}" \
                  --dbname "$${ZAMMAD_DB}" --command "SELECT 1"
         interval: 10s
         timeout: 5s
         start_period: 60s
         retries: 5
   
     zammad-railsserver:
       <<: *zammad-service
       command: ["zammad-railsserver"]
       healthcheck:
         test: ["CMD", "curl", "-sf", "http://127.0.0.1:${ZAMMAD_RAILSSERVER_PORT:-3000}"]
         interval: 30s
         timeout: 5s
         start_period: 120s
         retries: 3
   
     zammad-redis:
       image: redis:${REDIS_VERSION:-8.10.2-alpine}
       restart: ${RESTART:-always}
       volumes:
         - redis-data:/data
       healthcheck:
         test: ["CMD", "redis-cli", "ping"]
         interval: 10s
         timeout: 5s
         start_period: 10s
         retries: 5
   
     zammad-scheduler:
       <<: *zammad-service
       command: ["zammad-scheduler"]
   
     zammad-websocket:
       <<: *zammad-service
       command: ["zammad-websocket"]
   
   configs:
     postgresql-initdb:
       # Provisions the database that Zammad uses, owned by a dedicated login role.
       #
       # The postgres image unconditionally makes its bootstrap role a superuser. Zammad
       #   does not need any superuser capability - it only owns its own database and
       #   relies on the built-in plpgsql extension - so the application role is created
       #   here as a plain login role, rather than reusing the bootstrap role.
       #
       # This runs only while an empty data directory is initialised. Installations whose
       #   volume already exists keep the role layout they were created with, see the
       #   README for how to migrate those.
       #
       # The content is inlined so that this file stays self-contained and can be
       #   deployed as-is, for example by pasting it into Portainer. '$$' escapes Compose
       #   interpolation, so those variables are expanded inside the container instead.
       content: |
         #!/bin/bash
   
         set -o errexit
         set -o pipefail
   
         # initdb has already run by the time this hook does, so a rejected configuration
         #   cannot be repaired by correcting it and starting again: the data directory is
         #   populated and the entrypoint never processes this directory a second time.
         #   Say so, rather than leaving the operator with a role or database that is
         #   merely reported missing later on.
         zammad_abort() {
           echo "$1" >&2
           echo "Correct this, then remove the postgresql-data volume and start again. The" >&2
           echo "  database directory is already initialised, so this will not run twice." >&2
           exit 1
         }
   
         # Zammad must not reuse the bootstrap role, which is always a superuser. This
         #   cannot be enforced on later starts: the only reliable signal is this
         #   comparison, and checking the role's actual attributes instead would mark
         #   every installation predating this change as unhealthy.
         if [ "$${ZAMMAD_DB_USER}" = "$${POSTGRES_USER}" ]; then
           zammad_abort "POSTGRES_USER and POSTGRES_SUPERUSER must differ, the latter is a superuser."
         fi
   
         # The system databases exist already, so they would keep the bootstrap role as
         #   their owner, and Zammad would run inside a database it does not own.
         case "$${ZAMMAD_DB}" in
           postgres | template0 | template1)
             zammad_abort "POSTGRES_DB must not be one of PostgreSQL's system databases."
             ;;
         esac
   
         echo "Creating the '$${ZAMMAD_DB_USER}' role and the '$${ZAMMAD_DB}' database…"
   
         # The identifiers and the password are passed as psql variables, so that psql
         #   quotes them and no shell quoting can leak into the statements. The role owns
         #   the database, which already carries every privilege it needs on it.
         psql --variable ON_ERROR_STOP=1 \
              --username "$${POSTGRES_USER}" \
              --dbname "$${POSTGRES_DB}" \
              --variable role="$${ZAMMAD_DB_USER}" \
              --variable pass="$${ZAMMAD_DB_PASS}" \
              --variable db="$${ZAMMAD_DB}" <<'EOSQL'
         CREATE ROLE :"role" LOGIN PASSWORD :'pass';
         CREATE DATABASE :"db" OWNER :"role";
         EOSQL
   
   volumes:
     elasticsearch-data:
       driver: local
     postgresql-data:
       driver: local
     redis-data:
       driver: local
     zammad-backup:
       driver: local
     zammad-storage:
       driver: local
   ```

3. Set the values the compose file needs ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml))

   ```text
   These variables have no default in the compose file: AUTOWIZARD_JSON, AUTOWIZARD_RELATIVE_PATH, ELASTICSEARCH_ENABLED, ELASTICSEARCH_HOST, ELASTICSEARCH_NAMESPACE, ELASTICSEARCH_PASS, ELASTICSEARCH_PORT, ELASTICSEARCH_REINDEX and 60 more. Compose reads them from the shell or from a .env file next to the compose file.
   ```

4. Start it ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml))

   ```bash
   docker compose up -d
   ```

5. Open the app ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml))

   ```text
   Open http://localhost:8080. The compose file publishes host port 8080 for the zammad-nginx service.
   ```

Compose file check: config_ok on 2026-10-22.

## Gotchas and maintenance

### Gotchas

- The compose file publishes host ports: Host ports: 8080 (zammad-nginx). A port without a host address listens on all host network interfaces. ([source](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml), checked 2026-10-22)

### Signals

Computed from a GitHub snapshot taken 2026-10-22.

- Open issues and pull requests: 471
- Stars: 6025, forks: 1080
- Last push: 2026-10-22 (0 days ago)
- Latest release: none recorded

## Sources

- [Repository: https://github.com/zammad/zammad](https://github.com/zammad/zammad)
- [License: https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/LICENSE](https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/LICENSE)
- [Business model: https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/README.md](https://github.com/zammad/zammad/blob/e31c7d12aaa00469aeab66e788ef7c165c193e18/README.md)
- [Compose file: https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml](https://github.com/zammad/zammad-docker-compose/blob/16f43d8d5c7a31dbc05afe1bd4a2942d037c7da1/docker-compose.yml)

---
Canonical: https://openlogs.fyi/tools/zammad  
Last updated: 2026-10-22
