Zammad
Web-based helpdesk and customer support system.
- Replaces
-
Paid helpdesk and customer support services, such as Zendesk.
All open-source alternatives to Zendesk
- Difficulty
-
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.
- Stack
Ruby (language), PostgreSQL (database), Redis (database)
- Deployment
docker_compose
- Requirements
Not stated.
- License
-
AGPL-3.0(OSI-approved open source) - Business model
-
Unknown
The README at the pinned commit does not state a business model, so it stays unknown.
Source: github.com/zammad/zammad/blob/e31c7d1/README.md, read on .
- Repository
GitHub stats
- Stars
- 6,025
- Forks
- 1,080
- Open issues and pull requests
- 471
- Last push
- 2026-10-22
From the GitHub API on . These numbers are a snapshot, not live. GitHub detected the license as AGPL-3.0.
Compose file
---
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
From a separate packaging repository: github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml.
Compose file from zammad/zammad-docker-compose, the project's own packaging repository.
Install
- Docker with the Compose plugin (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
A host with Docker and the Docker Compose plugin. This entry runs from a compose file.
- Save the compose file as docker-compose.yml (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
Use the compose file shown above.
- Set the values the compose file needs (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
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.
- Start it (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
docker compose up -d - Open the app (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
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. (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml, checked 2026-10-22)
Signals
- Open issues and pull requests: 471
- Last push: 2026-10-22 (0 days before this page was built)
- Latest release: none recorded