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.compointing 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 <<'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 <<'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 <<'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 psshows web, websocket, scheduler, PostgreSQL, Redis, and Elasticsearch running.curl -I https://helpdesk.example.comreturns 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
- Deploy OpenProject with Docker Compose, Caddy, PostgreSQL, and Redis
- Deploy Wazuh with Docker Compose, Caddy, and a single-node indexer
- Deploy Listmonk with Docker Compose, Caddy, and PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.