Skip to Content

Production Guide: Deploy Zammad with Docker Compose + Caddy + PostgreSQL + Elasticsearch on Ubuntu

Run a production-oriented open-source helpdesk with HTTPS, private data services, search, backups, and verification.

Zammad is a practical open-source helpdesk for teams that have outgrown shared inboxes but do not want every customer conversation, internal request, and support workflow locked inside a proprietary ticketing platform. A common real-world use case is a managed services team, SaaS support desk, or internal IT group that needs email-to-ticket handling, agent roles, customer history, SLA discipline, and searchable conversations on infrastructure it controls. This guide deploys Zammad on a single Ubuntu server with Docker Compose, PostgreSQL for durable ticket data, Elasticsearch for search, and Caddy for automatic HTTPS.

The goal is not just to make the login screen appear. A production-oriented helpdesk must survive host reboots, keep secrets out of the Compose file, avoid exposing database and search ports, send reliable email, support repeatable backups, and provide operators with clear checks before agents start using it with real customers. The pattern below follows the current SysBrix Guides style: one understandable host, clear persistent volumes, private application networking, HTTPS at the edge, verification commands, and a recovery path that can be tested before the first critical ticket lands.

Architecture and flow overview

The deployment uses Caddy as the only public web entry point. Caddy listens on ports 80 and 443, obtains certificates from Let's Encrypt, and proxies requests to the Zammad web container on localhost. Zammad runs as multiple containers: the Rails web service, websocket service, background scheduler, PostgreSQL database, Elasticsearch search node, Redis cache, and an Nginx front container used by the official stack. PostgreSQL, Elasticsearch, and Redis stay on the private Docker network; they are not published to the internet.

For operators, the important flow is simple. A user opens https://helpdesk.example.com. Caddy terminates TLS and forwards traffic to the local Zammad frontend. The Zammad application reads ticket metadata from PostgreSQL, uses Redis for transient work, and indexes searchable content in Elasticsearch. Email delivery should be configured through an authenticated SMTP relay rather than a local unauthenticated mail daemon. Backups must include PostgreSQL dumps and the application volumes that hold uploaded attachments and configuration state.

Prerequisites

  • An Ubuntu 22.04 or 24.04 server with at least 4 CPU cores and 8 GB RAM. Elasticsearch makes tiny hosts frustrating.
  • A DNS record such as helpdesk.example.com pointing to the server public IP.
  • Ports 80 and 443 open from the internet; administrative SSH restricted to trusted networks.
  • SMTP credentials for outbound notifications and mailbox credentials if you plan to ingest support email.
  • A backup target outside the server, such as S3-compatible storage, another VPS, or a secured NAS.

Step-by-step deployment

1) Install Docker, Compose, Caddy, and firewall basics

Start from a patched server and install the core runtime. The firewall allows web traffic and limits SSH to your operational policy. Replace the example SSH rule with your own trusted source range before applying it on a production host.

sudo apt update
sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw git
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo apt -y install caddy
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
sudo ufw --force enable
docker version
docker compose version

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

2) Create the application layout and secrets

Keep Zammad under a dedicated directory so Compose files, environment values, backups, and operational notes stay together. Use generated passwords instead of memorable strings. Store the .env file with restrictive permissions and keep it out of Git.

sudo mkdir -p /opt/zammad/{data,backups}
sudo chown -R "$USER":"$USER" /opt/zammad
cd /opt/zammad
cat > .env <<'EOF'
POSTGRES_USER=zammad
POSTGRES_PASSWORD=replace_with_a_long_random_database_password
POSTGRES_DB=zammad
REDIS_URL=redis://redis:6379
ELASTICSEARCH_HOST=elasticsearch
ELASTICSEARCH_PORT=9200
ZAMMAD_HOSTNAME=helpdesk.example.com
RAILS_ENV=production
EOF
chmod 600 .env
openssl rand -base64 36

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

3) Define the Docker Compose stack

The stack below follows the official container split but keeps all state in named volumes. Zammad services depend on PostgreSQL, Redis, and Elasticsearch health. The Nginx frontend is bound only to localhost, which means Caddy can reach it from the host while the container is not directly exposed to the public internet.

cat > docker-compose.yml <&lt'EOF'
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
    restart: unless-stopped
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data

  zammad-init:
    image: zammad/zammad:latest
    env_file: .env
    command: ["zammad-init"]
    depends_on:
      postgres:
        condition: service_healthy
      elasticsearch:
        condition: service_started
      redis:
        condition: service_started
    volumes:
      - zammad-data:/opt/zammad

  zammad-railsserver:
    image: zammad/zammad:latest
    restart: unless-stopped
    env_file: .env
    command: ["zammad-railsserver"]
    depends_on:
      zammad-init:
        condition: service_completed_successfully
    volumes:
      - zammad-data:/opt/zammad

  zammad-websocket:
    image: zammad/zammad:latest
    restart: unless-stopped
    env_file: .env
    command: ["zammad-websocket"]
    depends_on:
      - zammad-railsserver
    volumes:
      - zammad-data:/opt/zammad

  zammad-scheduler:
    image: zammad/zammad:latest
    restart: unless-stopped
    env_file: .env
    command: ["zammad-scheduler"]
    depends_on:
      - zammad-railsserver
    volumes:
      - zammad-data:/opt/zammad

  zammad-nginx:
    image: zammad/zammad:latest
    restart: unless-stopped
    env_file: .env
    command: ["zammad-nginx"]
    depends_on:
      - zammad-railsserver
      - zammad-websocket
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - zammad-data:/opt/zammad

volumes:
  postgres-data:
  redis-data:
  elasticsearch-data:
  zammad-data:
EOF

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

4) Configure Caddy for HTTPS

Caddy provides the public TLS endpoint and forwards traffic to the localhost binding above. The security headers are conservative and should be reviewed if you later add SSO, embedded dashboards, or custom integrations that require different frame or content policies.

sudo tee /etc/caddy/Caddyfile >/dev/null <&lt'EOF'
helpdesk.example.com {
  encode zstd gzip
  reverse_proxy 127.0.0.1:8080
  header {
    X-Content-Type-Options nosniff
    X-Frame-Options SAMEORIGIN
    Referrer-Policy strict-origin-when-cross-origin
  }
}
EOF
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

5) Start Zammad and complete first-run setup

Bring up the stack, watch the initial database migration, and then open the site in a browser. Create the first administrator, configure the public URL, and connect outbound email before inviting agents. Do not connect a high-volume support mailbox until you have confirmed search indexing and backups.

cd /opt/zammad
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=120 zammad-railsserver
curl -I https://helpdesk.example.com

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

Configuration and secrets handling best practices

Treat the helpdesk as a business system, not a disposable web app. Database passwords, SMTP credentials, OAuth client secrets, and mailbox tokens should live in .env or an external secret manager, not inside tickets, documentation pages, or shell history. Restrict file permissions, rotate credentials when staff leave, and document who controls DNS, SMTP, and backup storage. If Zammad will hold customer data, define retention expectations before importing years of mailbox history.

Use separate mailboxes for inbound support and outbound notifications where possible. Configure SPF, DKIM, and DMARC for the sending domain so ticket notifications do not land in spam. For administrator access, enable strong passwords and consider placing the helpdesk behind an identity-aware access layer if it is strictly internal. Keep Elasticsearch private; it contains indexed ticket content and should never be reachable from the internet.

Backups and recovery routine

A useful backup is one you have restored. Schedule PostgreSQL dumps, keep a copy of the Compose project, and include the Zammad data volume because attachments and application state matter during recovery. The example below writes local backups first; a production system should sync them to off-host storage immediately after creation.

cat > /opt/zammad/backup-zammad.sh <&lt'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/zammad
stamp=$(date +%Y%m%d-%H%M%S)
mkdir -p backups/$stamp
docker compose exec -T postgres pg_dump -U zammad zammad > backups/$stamp/postgres.sql
docker run --rm -v zammad_zammad-data:/data -v /opt/zammad/backups/$stamp:/backup alpine tar czf /backup/zammad-data.tgz -C /data .
cp docker-compose.yml .env backups/$stamp/
find backups -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
EOF
chmod 700 /opt/zammad/backup-zammad.sh
/opt/zammad/backup-zammad.sh

If the copy button is unavailable in your browser, select the block text manually and paste it into your terminal.

Verification checklist

  • docker compose ps shows web, websocket, scheduler, PostgreSQL, Redis, and Elasticsearch running.
  • curl -I https://helpdesk.example.com returns a 200 or 302 over HTTPS, not a certificate warning.
  • The first administrator can log in, create a test ticket, assign it, add an internal note, and close it.
  • Outbound email sends a notification to a test mailbox and passes SPF/DKIM alignment.
  • Search returns the test ticket content after indexing.
  • A backup completes and a restore rehearsal has been documented for a non-production host.

Common issues and fixes

Caddy cannot issue a certificate

Confirm the DNS record points to the server, ports 80 and 443 are reachable, and no other service is already bound to those ports. Check journalctl -u caddy for ACME errors. If the server is behind a cloud firewall, open the ports there as well as with UFW.

Zammad starts but search does not work

Elasticsearch may still be warming up, short on memory, or blocked by a failed migration. Review docker compose logs elasticsearch and verify the container has enough memory. For production, avoid starving Elasticsearch; ticket search is central to agent productivity.

Email notifications never arrive

Use an authenticated SMTP relay and verify sender domain DNS. Many cloud providers restrict direct outbound port 25, so a proper relay is usually more reliable than local mail delivery. Test with a low-risk mailbox before connecting customer-facing addresses.

Attachments disappear after redeploy

Make sure Zammad data is stored in the named zammad-data volume and included in the backup routine. Avoid replacing named volumes during cleanup commands. Never run destructive Docker prune commands on production without reviewing which volumes will be removed.

The site shows a 502 gateway error

Check whether zammad-nginx is listening on 127.0.0.1:8080 and whether the Rails server finished booting. A mismatch between the Caddy reverse proxy port and the Compose port binding is a common cause. The Caddyfile uses reverse_proxy 127.0.0.1:8080, so the Compose stack must publish that exact host binding.

FAQ

Can Zammad run on a very small VPS?

It can start on small hardware, but Elasticsearch makes a 1 or 2 GB VPS a poor production choice. For a real helpdesk, begin with at least 8 GB RAM and monitor memory pressure after importing mail.

Should PostgreSQL or Elasticsearch be exposed for reporting?

No. Keep both private. If reporting is required, build controlled exports, read replicas, or API-based integrations rather than opening database and search ports to analysts or the internet.

How often should I back up the stack?

For an active support desk, run backups at least daily and consider more frequent database dumps during business hours. Always copy backups off the host and test restores before relying on them.

Can I put Zammad behind SSO?

Yes, but configure and test SSO after the base deployment is stable. Keep a documented break-glass administrator path so operators can recover access if the identity provider has an outage.

What should I monitor first?

Monitor HTTPS availability, container restarts, PostgreSQL disk usage, Elasticsearch heap pressure, queue activity, and outbound mail failures. A helpdesk outage is often noticed first as missing email or slow search.

How should upgrades be handled?

Read release notes, take a fresh backup, pull new images in a maintenance window, and verify login, ticket creation, email delivery, and search afterward. Do not upgrade blindly while agents are handling live customer conversations.

Is Docker Compose enough for production?

For a small or mid-sized helpdesk, a carefully operated single-host Compose deployment can be appropriate. Larger environments may prefer managed PostgreSQL, dedicated Elasticsearch, centralized logging, and a more formal deployment pipeline.

Internal links

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Production Guide: Deploy Vikunja with Docker Compose + Caddy + PostgreSQL on Ubuntu
Run Vikunja as a secure team task-management platform with HTTPS, PostgreSQL, backups, and practical operations checks.