OpenLogs

All tools

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)

Read from github.com/zammad/zammad/blob/e31c7d1/LICENSE

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

zammad/zammad

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

Config check passed docker compose config exited 0 on this exact file on 2026-10-22.
docker-compose.yml Version 1
---
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

  1. 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.

  2. 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.

  3. 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.

  4. Start it (github.com/zammad/zammad-docker-compose/blob/16f43d8/docker-compose.yml)
    docker compose up -d
  5. 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

Signals

  • Open issues and pull requests: 471
  • Last push: 2026-10-22 (0 days before this page was built)
  • Latest release: none recorded