Can I self-host Zendesk?
YES, IF · ONGOING OPS— setup effort 4 of 4YES, IF — it's called Zammad. It takes one prompt, a 6144 MB VPS, and about 300 minutes. That is $275 a month you stop paying Zendesk — $3,300 a year on the Suite Team plan, 5 seats assumed.
Why people pay for Zendesk
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Zendesk turns a support address into a system: every mail, chat and form becomes a ticket with an owner, a clock and a history, and the help centre, the reporting and the escalation rules come already wired to each other. What you are renting is the part nobody enjoys building, the routing and the SLAs and the audit trail, plus a per-agent meter that makes the bill grow exactly when the team does.
| Plan | List price | What it buys |
|---|---|---|
| Support Team | $19/mo per seat | Ticketing on its own, without the Suite's messaging, help centre and reporting. $19 per agent per month is the rate the page prints, paid yearly. |
| Suite Teamthe plan this page prices against | $55/mo per seat | The entry Suite plan and the usual starting point: $55 per agent per month, paid yearly. Five agents on this plan is $3,300 a year. |
| Suite Professional | $115/mo per seat | Marked most popular on the page. $115 per agent per month, paid yearly. |
| Suite Enterprise | quote only | Quote only. The page carries a Talk to Sales button where the other plans carry a number. |
| Copilot add-on | $50/mo per seat | An add-on rather than a plan, listed at $50 per agent per month paid yearly, which is where a Suite Team seat quietly becomes a $105 seat. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Zammad
One project, named before the prompt, so you know what you are about to install.
A shared support desk with agents, groups, escalation clocks and a customer portal, on your own hostname, with nobody counting seats.
The closest thing on this list to what Zendesk actually sells: tickets with owners, groups, states and escalation clocks, an agent interface built for a queue rather than a chat, a customer portal, triggers and schedulers, and a text module and knowledge base behind them. It is also the heaviest install here, seven containers and a six gigabyte floor, and the version this catalogue installs leaves out Elasticsearch and configures no mail, which is honestly two of the things you were paying for. Everything else is present and uncapped on agents.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Zammadour pickONGOING OPS— setup effort 4 of 4
A shared support desk with agents, groups, escalation clocks and a customer portal, on your own hostname, with nobody counting seats.
The closest thing on this list to what Zendesk actually sells: tickets with owners, groups, states and escalation clocks, an agent interface built for a queue rather than a chat, a customer portal, triggers and schedulers, and a text module and knowledge base behind them. It is also the heaviest install here, seven containers and a six gigabyte floor, and the version this catalogue installs leaves out Elasticsearch and configures no mail, which is honestly two of the things you were paying for. Everything else is present and uncapped on agents.
- ChatwootONE WEEKEND— setup effort 3 of 4
A website chat widget feeding a shared agent inbox you run yourself, with no per-seat meter and no per-resolution bill.
The right answer if the front door is a chat bubble on your website rather than a support address. Live chat into a shared agent inbox, a help centre, and a lighter four-container install than Zammad's. It ranks second here because Zendesk's centre of gravity is email ticketing with SLAs and agent workflow, and that is Zammad's ground, not Chatwoot's.
The swap
You'd run
Zammad
ONGOING OPS · ~300 min to running · 6144 MB RAM
Zendesk Suite Team · 5 seats assumed · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 6144 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~300 min3+ hours, then ongoing, through the first backup
The prompt
Two paths to the same Zammad: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
364 lines · 14,998 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Zammad 7.1.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
It becomes `ZAMMAD_FQDN`, the address Zammad writes into every link it builds, and its A record
must already point at this server.
Zammad needs 6144 MB of RAM available and 20 GB free on /srv. Six gigabytes is upstream's own
minimum for a stack without Elasticsearch, which is this one. Every image publishes amd64 and
arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 6144 MB or free disk is under 20 GB, print both numbers and stop.
Four Rails processes hold the application in memory at once, and the OOM killer arrives
mid-schema-load. If `dig +short` prints nothing, print that and stop.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/zammad /srv/zammad/backups /srv/zammad/redis
sudo install -d -m 750 -o 1000 -g 1000 /srv/zammad/storage
sudo install -d -m 700 /srv/zammad/postgres
ls -la /srv/zammad
```
Assert: `backups` and `redis` are owned by the login user, `storage` by uid `1000`, and
`postgres` is mode `700` owned by root. The Zammad image runs as uid 1000 and fails on first
boot if it cannot write `storage`. The PostgreSQL image chowns its own data directory, so leave
it alone.
## 3. Secrets
One secret: the PostgreSQL password. Upstream ships `zammad` as its default, so this step
replaces a published credential rather than inventing a requirement. Generate it on the server,
do not print it, do not repeat it in your summary, keep it out of any log line.
```bash
umask 077
cat > /srv/zammad/.env <<EOF
ZAMMAD_FQDN=<DOMAIN>
ZAMMAD_HTTP_TYPE=https
POSTGRESQL_PASS=$(openssl rand -hex 32)
EOF
chmod 600 /srv/zammad/.env
umask 022
ls -l /srv/zammad/.env
```
Assert: mode `-rw-------`. Replace `<DOMAIN>` on the first line with the real hostname before
writing it. No human signs in with this value; the administrator account is made in a browser
in step 7. Tell the user this file is half the backup: a database restored beside a
different .env does not open.
## 4. compose.yml
```bash
cat > /srv/zammad/compose.yml <<'EOF'
# Zammad · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker compose ..... https://docs.zammad.org/en/latest/install/docker-compose.html
# scenarios .......... https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# variable reference . https://docs.zammad.org/en/latest/appendix/environment-variables.html
#
# Seven services. Four are one Zammad image under different commands:
# railsserver answers the browser, websocket carries live updates, scheduler
# works the job queue, nginx serves the assets and routes /ws. PostgreSQL,
# Redis and memcached are the prerequisites upstream names. Their own file
# adds three more, each left out here: Elasticsearch through their
# ELASTICSEARCH_ENABLED switch, at the cost of full-text search; the nightly
# backup container, since step 8 takes a dump that leaves the box; and the
# migration container, run once by hand so its output is on screen. Digests
# read from Docker Hub on 2026-08-07; all four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: zammad
# The four Zammad processes share one image; compose ignores x- keys.
x-zammad: &zammad
image: zammad/zammad:7.1.2-0003@sha256:1ce0e929fac75f83f3e7534e9eb7aabfc3596cffbd00e25393be79709b9bea0c
restart: unless-stopped
init: true
env_file: /srv/zammad/.env
environment:
POSTGRESQL_HOST: zammad-postgresql
POSTGRESQL_DB: zammad_production
POSTGRESQL_USER: zammad
MEMCACHE_SERVERS: zammad-memcached:11211
REDIS_URL: redis://zammad-redis:6379
# Upstream's own switch for a stack with no Elasticsearch in it.
ELASTICSEARCH_ENABLED: "false"
# Caddy terminates TLS, so nginx is told the scheme it cannot see.
NGINX_SERVER_SCHEME: https
# Clients reach nginx over the compose network, never over loopback.
RAILS_TRUSTED_PROXIES: 127.0.0.1,::1,172.16.0.0/12
volumes:
- /srv/zammad/storage:/opt/zammad/storage
depends_on:
zammad-postgresql:
condition: service_healthy
zammad-redis:
condition: service_healthy
zammad-memcached:
condition: service_healthy
services:
zammad-postgresql:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: zammad-db
restart: unless-stopped
environment:
POSTGRES_DB: zammad_production
POSTGRES_USER: zammad
POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
volumes:
- /srv/zammad/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zammad -d zammad_production"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
zammad-redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: zammad-redis
restart: unless-stopped
volumes:
- /srv/zammad/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
zammad-memcached:
image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
container_name: zammad-memcached
restart: unless-stopped
command: memcached -m 256M
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
interval: 10s
retries: 12
zammad-railsserver:
<<: *zammad
container_name: zammad-railsserver
command: ["zammad-railsserver"]
healthcheck:
# The first boot loads a large schema, hence the long start period.
test: ["CMD", "curl", "-sf", "http://127.0.0.1:3000"]
interval: 30s
start_period: 240s
retries: 5
zammad-websocket:
<<: *zammad
container_name: zammad-websocket
command: ["zammad-websocket"]
zammad-scheduler:
<<: *zammad
container_name: zammad-scheduler
command: ["zammad-scheduler"]
zammad-nginx:
<<: *zammad
container_name: zammad-nginx
command: ["zammad-nginx"]
init: false
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8169.
- "127.0.0.1:8169:8080"
depends_on:
zammad-railsserver:
condition: service_healthy
EOF
cd /srv/zammad && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. The scheduler is not scenery: it works the queue where
triggers, escalation clocks and notifications run, and without it nothing happens on schedule.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-zammad
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zammad · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.zammad.org/en/latest/install/docker-compose.html and
# https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed at this
# box; that hostname is also ZAMMAD_FQDN in .env.
<DOMAIN> {
# A helpdesk holds other people's names and complaints, so nothing here
# is framed, sniffed, or leaked through a referrer.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8169 is the loopback port compose publishes here, not open in the
# firewall. The Zammad nginx behind it routes /ws itself, so Caddy has
# one upstream.
reverse_proxy 127.0.0.1:8169
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` and the reload both exit 0. If validate fails, restore
/etc/caddy/Caddyfile.before-zammad, reload, and report what it objected to. Caddy gets the
certificate on first request and renews it.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so nothing changes on a Prompt Zero box:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects, 443/tcp is the way in, 443/udp is HTTP/3.
8169 is bound to 127.0.0.1, and 5432, 6379 and 11211 have no host port at all. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for
8169, 5432, 6379 or 11211.
## 7. Start and verify
The migration container runs first, once, in the foreground: it creates the database, loads the
schema, seeds it and writes the FQDN from .env into the settings. Expect minutes of output.
```bash
cd /srv/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/users
```
Assert all three, printing what you received. The init run exits 0, having reached PostgreSQL
and loaded the schema; the loop ends on `200`; the unauthenticated call to /api/v1/users prints
`401`, the security assert here. If any misses, stop, run
`docker compose logs --tail 40 zammad-railsserver` and name the cause: a `502` that never
becomes `200` means nginx is still waiting on the rails health check, and a connection failure
in the init run points at step 3. A running container is not success.
The first screen at https://<DOMAIN> shows the heading `Welcome!` above a button reading
`Set up a new system`.
STOP: tell the user to open https://<DOMAIN>, press `Set up a new system`, and work through the
wizard to create their administrator account, and wait. Do not continue until they confirm.
Tell them to put that password in their password manager as they type it: this install has no
mail, so there is no reset link behind it.
Once they confirm, shut the self-signup door Zammad ships open, then prove both facts:
```bash
cd /srv/zammad
docker compose exec -T zammad-railsserver bundle exec rails r "Setting.set('user_create_account', false)"
curl -sS -H 'Content-Type: application/json' -d '{"query":"{systemSetupInfo{status}}"}' https://<DOMAIN>/graphql
curl -sS -H 'Content-Type: application/json' -d '{"query":"{applicationConfig{key value}}"}' https://<DOMAIN>/graphql | grep -q user_create_account && echo "signup OPEN" || echo "signup CLOSED"
```
Assert: the first curl prints `"status":"done"`, Zammad confirming through its own API that an
administrator now exists, and the last line prints `signup CLOSED`, because Zammad hands
`user_create_account` to anonymous browsers only while it is on. Both must pass before you
report success.
## 8. First backup and restore
Two artifacts. Attachments live in the database on the default storage setting, so the dump is
the whole of the data; the config archive rebuilds the service around it.
```bash
cd /srv/zammad
docker compose exec -T zammad-postgresql pg_dump -U zammad -d zammad_production | gzip > /srv/zammad/backups/zammad-db-$(date +%F).sql.gz
sudo tar -czf /srv/zammad/backups/zammad-config-$(date +%F).tar.gz -C /srv/zammad compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/zammad/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Redis and memcached are in neither archive:
they hold sessions and caches, not durable data.
A backup on the same disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/zammad
scp vps:/srv/zammad/backups/* ~/backups/zammad/
```
To restore: `docker compose down`, `sudo rm -rf /srv/zammad/postgres`, recreate it as in step 2,
untar the config archive into /srv/zammad so `.env` is back before anything starts,
`docker compose up -d zammad-postgresql`, wait for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T zammad-postgresql psql -U zammad -d zammad_production`, run step
7's init command once, then `docker compose up -d`. Tell the user every ticket and attachment
they will ever have is in that one dump.
## 9. Updating later
Image tags are listed at https://hub.docker.com/r/zammad/zammad/tags and software versions at
https://github.com/zammad/zammad/tags. The tag carries a build number, which is why this pins
`7.1.2-0003` and not `7.1.2`. Back up first, then edit the image line in
/srv/zammad/compose.yml to the new tag and digest:
```bash
cd /srv/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
docker compose logs --tail 30 zammad-railsserver
```
That init run is not optional: the new image migrates the database it inherited, and skipping it
leaves every container waiting on migrations nobody ran. Then re-run step 7's checks.
## 10. What will probably go wrong
The wait after `docker compose up -d`. It returned in a second, `docker compose ps` showed
zammad-nginx as `Created` rather than running, and https://<DOMAIN> answered `502` for four
minutes. Nothing was wrong: nginx waits on the rails health check, which has a four-minute start
period because the first boot loads a great deal before answering anything. I tore the stack
down and started again, sure it had hung, and bought another four minutes. Let step 7's loop
run, and watch `docker compose logs -f zammad-railsserver` meanwhile.
## 11. Out of scope
- Do not configure SMTP, IMAP or POP3. Ticket-by-email is what most people eventually want
here, and it is a separate day's work with a mail provider; the web form, the agent interface
and the customer portal all work without it.
- Do not add Elasticsearch. This stack is built without it deliberately, and it costs another
container, four gigabytes and a reindex.
- Do not set `user_create_account` back to true. Step 7 asserts it is off, and an open signup
on a public helpdesk is an open door.
- Do not enable the built-in backup container or S3 storage. Step 8 owns the backup, and a
bucket hides attachments from it.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Zammad 7.1.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box. That hostname becomes `ZAMMAD_FQDN`, the address Zammad writes into every link it
builds, so pick the one you intend to keep.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `6144` MB available, at least `20` G free, `amd64` or `arm64`, and
your server's IP on the last line.
If you do not: six gigabytes is upstream's own minimum, and it is for a stack without
Elasticsearch, which is the one this builds. Four Rails processes hold the whole application in
memory at once, so a 4 GB box does not squeak through, it gets OOM-killed during the schema
load. An empty last line means the A record does not exist yet: add it, wait a minute, and run
`dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve and failed attempts count against a rate limit you cannot see.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/zammad /srv/zammad/backups /srv/zammad/redis
sudo install -d -m 750 -o 1000 -g 1000 /srv/zammad/storage
sudo install -d -m 700 /srv/zammad/postgres
ls -la /srv/zammad
```
You should see: `backups` and `redis` owned by you, `storage` owned by uid `1000`, and
`postgres` at mode `drwx------` owned by root.
If you do not: those two odd ownerships are both deliberate. The Zammad image runs as uid 1000
and cannot write a directory you own, and the PostgreSQL image chowns its own data directory
the first time it starts and refuses one that has already been chowned.
## 3. Secrets
One secret: the PostgreSQL password. Upstream ships `zammad` as the default value for it, so
this replaces a published credential rather than inventing a new one. It is generated here, on
the server, into a file only you can read.
```bash
umask 077
cat > /srv/zammad/.env <<EOF
ZAMMAD_FQDN=<DOMAIN>
ZAMMAD_HTTP_TYPE=https
POSTGRESQL_PASS=$(openssl rand -hex 32)
EOF
chmod 600 /srv/zammad/.env
umask 022
ls -l /srv/zammad/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/zammad/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten the
password, which is fine before the database exists and a problem afterwards: PostgreSQL keeps
the password it was created with, so a changed one on an existing volume shows up as an
authentication failure in the Zammad log rather than as anything about passwords.
Do not paste that file, the password, or any command output containing it into this chat
window. Nothing in this install asks you to.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/zammad/compose.yml <<'EOF'
# Zammad · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker compose ..... https://docs.zammad.org/en/latest/install/docker-compose.html
# scenarios .......... https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# variable reference . https://docs.zammad.org/en/latest/appendix/environment-variables.html
#
# Seven services. Four are one Zammad image under different commands:
# railsserver answers the browser, websocket carries live updates, scheduler
# works the job queue, nginx serves the assets and routes /ws. PostgreSQL,
# Redis and memcached are the prerequisites upstream names. Their own file
# adds three more, each left out here: Elasticsearch through their
# ELASTICSEARCH_ENABLED switch, at the cost of full-text search; the nightly
# backup container, since step 8 takes a dump that leaves the box; and the
# migration container, run once by hand so its output is on screen. Digests
# read from Docker Hub on 2026-08-07; all four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: zammad
# The four Zammad processes share one image; compose ignores x- keys.
x-zammad: &zammad
image: zammad/zammad:7.1.2-0003@sha256:1ce0e929fac75f83f3e7534e9eb7aabfc3596cffbd00e25393be79709b9bea0c
restart: unless-stopped
init: true
env_file: /srv/zammad/.env
environment:
POSTGRESQL_HOST: zammad-postgresql
POSTGRESQL_DB: zammad_production
POSTGRESQL_USER: zammad
MEMCACHE_SERVERS: zammad-memcached:11211
REDIS_URL: redis://zammad-redis:6379
# Upstream's own switch for a stack with no Elasticsearch in it.
ELASTICSEARCH_ENABLED: "false"
# Caddy terminates TLS, so nginx is told the scheme it cannot see.
NGINX_SERVER_SCHEME: https
# Clients reach nginx over the compose network, never over loopback.
RAILS_TRUSTED_PROXIES: 127.0.0.1,::1,172.16.0.0/12
volumes:
- /srv/zammad/storage:/opt/zammad/storage
depends_on:
zammad-postgresql:
condition: service_healthy
zammad-redis:
condition: service_healthy
zammad-memcached:
condition: service_healthy
services:
zammad-postgresql:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: zammad-db
restart: unless-stopped
environment:
POSTGRES_DB: zammad_production
POSTGRES_USER: zammad
POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
volumes:
- /srv/zammad/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zammad -d zammad_production"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
zammad-redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: zammad-redis
restart: unless-stopped
volumes:
- /srv/zammad/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
zammad-memcached:
image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
container_name: zammad-memcached
restart: unless-stopped
command: memcached -m 256M
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
interval: 10s
retries: 12
zammad-railsserver:
<<: *zammad
container_name: zammad-railsserver
command: ["zammad-railsserver"]
healthcheck:
# The first boot loads a large schema, hence the long start period.
test: ["CMD", "curl", "-sf", "http://127.0.0.1:3000"]
interval: 30s
start_period: 240s
retries: 5
zammad-websocket:
<<: *zammad
container_name: zammad-websocket
command: ["zammad-websocket"]
zammad-scheduler:
<<: *zammad
container_name: zammad-scheduler
command: ["zammad-scheduler"]
zammad-nginx:
<<: *zammad
container_name: zammad-nginx
command: ["zammad-nginx"]
init: false
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8169.
- "127.0.0.1:8169:8080"
depends_on:
zammad-railsserver:
condition: service_healthy
EOF
cd /srv/zammad && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/zammad/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/zammad/compose.yml` and paste again in one go. The scheduler service is the one
people delete to save memory and then miss: it works the queue where triggers, escalation
clocks and notifications run, and without it Zammad answers every page perfectly and does
nothing on schedule.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-zammad
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zammad · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.zammad.org/en/latest/install/docker-compose.html and
# https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed at this
# box; that hostname is also ZAMMAD_FQDN in .env.
<DOMAIN> {
# A helpdesk holds other people's names and complaints, so nothing here
# is framed, sniffed, or leaked through a referrer.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8169 is the loopback port compose publishes here, not open in the
# firewall. The Zammad nginx behind it routes /ws itself, so Caddy has
# one upstream.
reverse_proxy 127.0.0.1:8169
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-zammad /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the Zammad nginx container,
which is why `NGINX_SERVER_SCHEME` is `https` in the compose file: without it that container
would read the scheme off a plain connection and hand out `http://` links for a site only
reachable over https.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8169`, `5432`, `6379` or `11211`.
If you do not: delete anything for those four with `sudo ufw delete allow 8169`. 8169 is bound
to 127.0.0.1 by the compose file and the other three are never published at all, so there is no
host port a firewall rule could apply to. `Status: inactive` is a different problem: Prompt Zero
left this firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it
back before you go any further.
## 7. Start and verify
The migration container runs first, once, in the foreground. It creates the database, loads the
schema and seeds it. This is the long step: minutes of output, and it is meant to be.
```bash
cd /srv/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/users
```
You should see, in order: migration lines from the init run ending with a shell prompt back,
the loop climbing to `200`, then `401` from the last command.
If you do not: the `401` is the one worth understanding. It means the API is up and refusing a
call with no session, which is the answer you want from a helpdesk on the public internet. A
`404` in its place means Caddy is not reaching the container: check `docker compose ps`. If the
loop never reaches `200`, run `docker compose logs --tail 40 zammad-railsserver`. A connection
failure inside the init run points at step 3 and an `.env` with no password line.
The first screen at https://<DOMAIN> shows the heading `Welcome!` above a button reading
`Set up a new system`. Open it, press that button, and work through the wizard to create your
administrator account. Put that password in your password manager as you type it: this install
configures no mail, so there is no reset link behind it.
Once the wizard is done, shut the self-signup door Zammad ships open:
```bash
cd /srv/zammad
docker compose exec -T zammad-railsserver bundle exec rails r "Setting.set('user_create_account', false)"
curl -sS -H 'Content-Type: application/json' -d '{"query":"{systemSetupInfo{status}}"}' https://<DOMAIN>/graphql
curl -sS -H 'Content-Type: application/json' -d '{"query":"{applicationConfig{key value}}"}' https://<DOMAIN>/graphql | grep -q user_create_account && echo "signup OPEN" || echo "signup CLOSED"
```
You should see: `"status":"done"` in the first response, then `signup CLOSED` on the last line.
If you do not: `"status":"new"` means the wizard did not finish, so go back and complete it.
`signup OPEN` means the setting did not take: re-run the first line and check it prints no
error. Zammad hands `user_create_account` to anonymous browsers only while it is on, so its
absence from that response is the proof the door is shut. Both of these matter more here than
anywhere else in the install, because until the second one prints `CLOSED` anybody who finds
your hostname can make themselves an account on your helpdesk.
## 8. First backup and restore
Two artifacts. Attachments live in the database on the default storage setting, so the dump is
the whole of the data; the config archive holds the files that rebuild the service around it.
```bash
cd /srv/zammad
docker compose exec -T zammad-postgresql pg_dump -U zammad -d zammad_production | gzip > /srv/zammad/backups/zammad-db-$(date +%F).sql.gz
sudo tar -czf /srv/zammad/backups/zammad-config-$(date +%F).tar.gz -C /srv/zammad compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/zammad/backups/
```
You should see: two files, the dump a few hundred kilobytes on a fresh install and the config
archive a few kilobytes. Nothing goes offline: `pg_dump` snapshots a running database
consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/zammad
scp vps:/srv/zammad/backups/* ~/backups/zammad/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/zammad/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty helpdesk:
```bash
cd /srv/zammad
docker compose down
sudo rm -rf /srv/zammad/postgres
sudo install -d -m 700 /srv/zammad/postgres
docker compose up -d zammad-postgresql
sleep 30
gunzip -c /srv/zammad/backups/zammad-db-$(date +%F).sql.gz | docker compose exec -T zammad-postgresql psql -U zammad -d zammad_production
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command,
and your administrator login still works.
If you do not: `role "zammad" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this: every ticket, every customer and every attachment anyone ever sends you is a row in
that database, and the `.env` in the config archive is what the next PostgreSQL is created with.
## 9. Updating later
Image tags are listed at https://hub.docker.com/r/zammad/zammad/tags and software versions at
https://github.com/zammad/zammad/tags. The tag carries a build number after the version, which
is why this install pins `7.1.2-0003` and not `7.1.2`. Take both backup artifacts first, then
edit the `image:` line in /srv/zammad/compose.yml to the new tag and its digest.
```bash
cd /srv/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
docker compose logs --tail 30 zammad-railsserver
```
You should see: migration output from the init run, then the server starting, and no repeating
restart.
If you do not: put the old tag and digest back and run the same commands. That init run is not
optional and it is the step people skip: the new image migrates the database it inherited, and
without it every other container sits waiting for migrations nobody ran, which looks exactly
like a hung boot.
## 10. What will probably go wrong
The wait after `docker compose up -d`. It returned in a second, `docker compose ps` showed
zammad-nginx as `Created` rather than running, and https://<DOMAIN> answered `502` for four
minutes. Nothing was wrong: nginx waits on the rails health check, which has a four-minute
start period because the first boot loads a great deal before it answers anything. I tore the
stack down and started again, sure it had hung, and bought another four minutes. Let the loop
in step 7 run.
## 11. Out of scope
- Do not configure SMTP, IMAP or POP3. Ticket-by-email is what most people eventually want
here, and it is a separate day's work with a mail provider; the web form, the agent interface
and the customer portal all work without it.
- Do not add Elasticsearch. This stack is built without it deliberately, and it costs another
container, another four gigabytes and a reindex.
- Do not set `user_create_account` back to true. Step 7 proves it is off, and an open signup on
a public helpdesk is an open door.
- Do not enable the built-in backup container or S3 storage. Step 8 owns the backup, and a
bucket puts attachments where that backup cannot see them.373 lines · 16,059 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Zammad 7.1.2, with the PostgreSQL, Redis and memcached it needs, under
~/selfhost/zammad, answering at http://localhost:8169.
## 1. Preflight
Say this before step 2; it decides whether they want this at all. Only this computer can open
the helpdesk, so nobody they would support can reach it, and the scheduler that works the
queue stops whenever the machine sleeps.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
ID and codename print next, for step 2. Zammad needs 6144 MB of RAM available and 20 GB free on
the home disk, upstream's minimum without Elasticsearch; both architectures are published. On
macOS and Windows raise Docker Desktop's memory to 6 GB first. Under either floor, print both
numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/zammad/storage ~/selfhost/zammad/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 1000:1000 ~/selfhost/zammad/storage; fi
ls -la ~/selfhost/zammad
```
Assert: `ls -la` shows `storage` and `backups`. The Zammad image runs as uid 1000, so on
Linux `storage` is handed to it; elsewhere Docker Desktop's file sharing owns that and the
guarded line is a no-op. The database and Redis get Docker-managed volumes, those images
picking their own uids.
## 4. Secrets
One secret: the PostgreSQL password. Upstream ships `zammad` as its default, so this replaces a
published credential. Generate it here, print it nowhere, keep it out of summaries and logs.
```bash
umask 077
cat > ~/selfhost/zammad/.env <<EOF
ZAMMAD_FQDN=localhost:8169
ZAMMAD_HTTP_TYPE=http
POSTGRESQL_PASS=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/zammad/.env
umask 022
ls -l ~/selfhost/zammad/.env
```
Assert: mode `-rw-------`. Git Bash ships openssl, so this runs the same everywhere, and no
human signs in with the value: the administrator account is made in a browser in step 7. On
Windows the mode bits are advisory and the user's account is the real boundary.
## 5. compose.yml
```bash
cat > ~/selfhost/zammad/compose.yml <<'EOF'
# Zammad · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker compose ..... https://docs.zammad.org/en/latest/install/docker-compose.html
# scenarios .......... https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# variable reference . https://docs.zammad.org/en/latest/appendix/environment-variables.html
#
# Seven services, every path relative to ~/selfhost/zammad/ so one file works
# on macOS, Linux and Windows. The database and Redis are named volumes, not
# bind mounts: both images chown their data directories to uids Docker
# Desktop's Windows file sharing cannot grant on a home-directory bind mount.
# Four of the seven are one Zammad image under different commands. Upstream's
# Elasticsearch, backup and migration containers are left out, the first
# through their ELASTICSEARCH_ENABLED switch. Digests read from Docker Hub 2026-08-07;
# every image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: zammad
# The four Zammad processes share one image; compose ignores x- keys.
x-zammad: &zammad
image: zammad/zammad:7.1.2-0003@sha256:1ce0e929fac75f83f3e7534e9eb7aabfc3596cffbd00e25393be79709b9bea0c
restart: unless-stopped
init: true
env_file: ./.env
environment:
POSTGRESQL_HOST: zammad-postgresql
POSTGRESQL_DB: zammad_production
POSTGRESQL_USER: zammad
MEMCACHE_SERVERS: zammad-memcached:11211
REDIS_URL: redis://zammad-redis:6379
ELASTICSEARCH_ENABLED: "false"
NGINX_SERVER_SCHEME: http
RAILS_TRUSTED_PROXIES: 127.0.0.1,::1,172.16.0.0/12
volumes:
- ./storage:/opt/zammad/storage
depends_on:
zammad-postgresql:
condition: service_healthy
zammad-redis:
condition: service_healthy
zammad-memcached:
condition: service_healthy
services:
zammad-postgresql:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: zammad-db
restart: unless-stopped
environment:
POSTGRES_DB: zammad_production
POSTGRES_USER: zammad
POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
volumes:
- zammad-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zammad -d zammad_production"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
zammad-redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: zammad-redis
restart: unless-stopped
volumes:
- zammad-redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
zammad-memcached:
image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
container_name: zammad-memcached
restart: unless-stopped
command: memcached -m 256M
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
interval: 10s
retries: 12
zammad-railsserver:
<<: *zammad
container_name: zammad-railsserver
command: ["zammad-railsserver"]
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:3000"]
interval: 30s
start_period: 240s
retries: 5
zammad-websocket:
<<: *zammad
container_name: zammad-websocket
command: ["zammad-websocket"]
zammad-scheduler:
<<: *zammad
container_name: zammad-scheduler
command: ["zammad-scheduler"]
zammad-nginx:
<<: *zammad
container_name: zammad-nginx
command: ["zammad-nginx"]
init: false
ports:
# Loopback only: no other device on the wifi can reach 8169.
- "127.0.0.1:8169:8080"
depends_on:
zammad-railsserver:
condition: service_healthy
volumes:
zammad-pgdata:
zammad-redisdata:
EOF
cd ~/selfhost/zammad && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Seven services, one published port, two volumes.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve.
- No TLS. Nothing has a public name to certify, and browsers treat http://localhost as a secure
context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback.
8169 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet.
For a queue of other people's problems that is the trade. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/zammad/compose.yml
```
Assert: that prints `1`, the one published port `- "127.0.0.1:8169:8080"`. PostgreSQL, Redis
and memcached publish none.
## 7. Start and verify
The migration container runs first, once, in the foreground: it creates the database, loads the
schema and seeds it. Expect minutes of output.
```bash
cd ~/selfhost/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8169/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8169/api/v1/users
```
Assert all three, printing what you received: the init run exits 0, the loop ends on `200`, and
the unauthenticated call to /api/v1/users prints `401`, the security assert here. If any
misses, stop, run `docker compose logs --tail 40 zammad-railsserver` and name the cause. A
`502` that never becomes `200` means nginx is still waiting on the rails health check; `port is
already allocated` means something else holds 8169, which `lsof -nP -iTCP:8169` names on macOS
and Linux and `netstat -ano | findstr :8169` on Windows. A running container is not success.
The first screen at http://localhost:8169 shows the heading `Welcome!` above a button reading
`Set up a new system`.
STOP: tell the user to open http://localhost:8169, press `Set up a new system`, and work
through the wizard to create their administrator account, and wait.
Do not continue until they confirm. Tell them to put that password in a password manager as
they type it: there is no mail here and so no reset link.
Once they confirm, shut the self-signup door Zammad ships open, then prove both facts:
```bash
cd ~/selfhost/zammad
docker compose exec -T zammad-railsserver bundle exec rails r "Setting.set('user_create_account', false)"
curl -sS -H 'Content-Type: application/json' -d '{"query":"{systemSetupInfo{status}}"}' http://localhost:8169/graphql
curl -sS -H 'Content-Type: application/json' -d '{"query":"{applicationConfig{key value}}"}' http://localhost:8169/graphql | grep -q user_create_account && echo "signup OPEN" || echo "signup CLOSED"
```
Assert: the first curl prints `"status":"done"`, Zammad confirming an administrator now exists,
and the last prints `signup CLOSED`, because Zammad hands `user_create_account` to anonymous
browsers only while it is on. Both must pass.
## 8. First backup and restore
Two artifacts. Attachments live in the database on the default storage setting, so the dump is
all of the data; the config archive rebuilds the service around it.
```bash
cd ~/selfhost/zammad
docker compose exec -T zammad-postgresql pg_dump -U zammad -d zammad_production | gzip > ~/selfhost/zammad/backups/zammad-db-$(date +%F).sql.gz
tar -C ~/selfhost/zammad -czf ~/selfhost/zammad/backups/zammad-config-$(date +%F).tar.gz compose.yml .env storage
ls -lh ~/selfhost/zammad/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. Redis and memcached hold caches, so they are skipped.
Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination off this computer, a sync folder or a USB stick, and
copy both there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert: they confirm
both filenames are there, or say that this install has no backup.
To restore, in this order. `cd ~/selfhost/zammad`, untar the config archive there first so
compose.yml and .env are back before any container starts, because PostgreSQL takes its
password from .env the moment it initialises an empty volume. Then `docker compose down -v`,
the one place `-v` belongs, `docker compose up -d zammad-postgresql`, wait 30 seconds for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T zammad-postgresql psql -U zammad -d zammad_production`, run step 7's
init once, then `docker compose up -d`. That is the disaster plan.
## 9. Updating later
Image tags are at https://hub.docker.com/r/zammad/zammad/tags and versions at
https://github.com/zammad/zammad/tags. The tag carries a build number, which is why this pins
`7.1.2-0003`. Back up first, then edit the image line in ~/selfhost/zammad/compose.yml to the
new tag and digest:
```bash
cd ~/selfhost/zammad
docker compose pull
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
docker compose logs --tail 30 zammad-railsserver
```
That init run is not optional: the new image migrates the database it inherited, and skipping
it leaves every container waiting on migrations nobody ran. Re-run step 7's checks after.
## 10. What will probably go wrong
I closed the laptop for an hour, came back, and a ticket I had raised showed no escalation and
no reminder. Nothing was broken: the machine had slept, Docker Desktop with it, and the
scheduler had not been running to notice anything was due. A reboot does the same, because
`restart: unless-stopped` acts only once the Docker daemon is up. Turn on Docker Desktop's
start-at-login, run `cd ~/selfhost/zammad && docker compose up -d` after one, and read every
escalation clock as "while this computer was awake".
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not configure SMTP, IMAP or POP3. Ticket-by-email needs a provider that can reach a public
address, and nothing here has one.
- Do not add Elasticsearch. This stack is built without it, and it costs a container, four
gigabytes and a reindex.
- Do not set `user_create_account` back to true, and do not rebind 8169 to 0.0.0.0 so a phone
can reach it. Step 7 asserts signup is off; both undo that.compose.local.ymlthe services, pinned · local layout117 lines
# Zammad · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker compose ..... https://docs.zammad.org/en/latest/install/docker-compose.html
# scenarios .......... https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# variable reference . https://docs.zammad.org/en/latest/appendix/environment-variables.html
#
# Seven services, every path relative to ~/selfhost/zammad/ so one file works
# on macOS, Linux and Windows. The database and Redis are named volumes, not
# bind mounts: both images chown their data directories to uids Docker
# Desktop's Windows file sharing cannot grant on a home-directory bind mount.
# Four of the seven are one Zammad image under different commands. Upstream's
# Elasticsearch, backup and migration containers are left out, the first
# through their ELASTICSEARCH_ENABLED switch. Digests read from Docker Hub 2026-08-07;
# every image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: zammad
# The four Zammad processes share one image; compose ignores x- keys.
x-zammad: &zammad
image: zammad/zammad:7.1.2-0003@sha256:1ce0e929fac75f83f3e7534e9eb7aabfc3596cffbd00e25393be79709b9bea0c
restart: unless-stopped
init: true
env_file: ./.env
environment:
POSTGRESQL_HOST: zammad-postgresql
POSTGRESQL_DB: zammad_production
POSTGRESQL_USER: zammad
MEMCACHE_SERVERS: zammad-memcached:11211
REDIS_URL: redis://zammad-redis:6379
ELASTICSEARCH_ENABLED: "false"
NGINX_SERVER_SCHEME: http
RAILS_TRUSTED_PROXIES: 127.0.0.1,::1,172.16.0.0/12
volumes:
- ./storage:/opt/zammad/storage
depends_on:
zammad-postgresql:
condition: service_healthy
zammad-redis:
condition: service_healthy
zammad-memcached:
condition: service_healthy
services:
zammad-postgresql:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: zammad-db
restart: unless-stopped
environment:
POSTGRES_DB: zammad_production
POSTGRES_USER: zammad
POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
volumes:
- zammad-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zammad -d zammad_production"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
zammad-redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: zammad-redis
restart: unless-stopped
volumes:
- zammad-redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
zammad-memcached:
image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
container_name: zammad-memcached
restart: unless-stopped
command: memcached -m 256M
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
interval: 10s
retries: 12
zammad-railsserver:
<<: *zammad
container_name: zammad-railsserver
command: ["zammad-railsserver"]
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:3000"]
interval: 30s
start_period: 240s
retries: 5
zammad-websocket:
<<: *zammad
container_name: zammad-websocket
command: ["zammad-websocket"]
zammad-scheduler:
<<: *zammad
container_name: zammad-scheduler
command: ["zammad-scheduler"]
zammad-nginx:
<<: *zammad
container_name: zammad-nginx
command: ["zammad-nginx"]
init: false
ports:
# Loopback only: no other device on the wifi can reach 8169.
- "127.0.0.1:8169:8080"
depends_on:
zammad-railsserver:
condition: service_healthy
volumes:
zammad-pgdata:
zammad-redisdata:agent-readable mirror: /self-host/zendesk.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned118 lines
# Zammad · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker compose ..... https://docs.zammad.org/en/latest/install/docker-compose.html
# scenarios .......... https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# variable reference . https://docs.zammad.org/en/latest/appendix/environment-variables.html
#
# Seven services. Four are one Zammad image under different commands:
# railsserver answers the browser, websocket carries live updates, scheduler
# works the job queue, nginx serves the assets and routes /ws. PostgreSQL,
# Redis and memcached are the prerequisites upstream names. Their own file
# adds three more, each left out here: Elasticsearch through their
# ELASTICSEARCH_ENABLED switch, at the cost of full-text search; the nightly
# backup container, since step 8 takes a dump that leaves the box; and the
# migration container, run once by hand so its output is on screen. Digests
# read from Docker Hub on 2026-08-07; all four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: zammad
# The four Zammad processes share one image; compose ignores x- keys.
x-zammad: &zammad
image: zammad/zammad:7.1.2-0003@sha256:1ce0e929fac75f83f3e7534e9eb7aabfc3596cffbd00e25393be79709b9bea0c
restart: unless-stopped
init: true
env_file: /srv/zammad/.env
environment:
POSTGRESQL_HOST: zammad-postgresql
POSTGRESQL_DB: zammad_production
POSTGRESQL_USER: zammad
MEMCACHE_SERVERS: zammad-memcached:11211
REDIS_URL: redis://zammad-redis:6379
# Upstream's own switch for a stack with no Elasticsearch in it.
ELASTICSEARCH_ENABLED: "false"
# Caddy terminates TLS, so nginx is told the scheme it cannot see.
NGINX_SERVER_SCHEME: https
# Clients reach nginx over the compose network, never over loopback.
RAILS_TRUSTED_PROXIES: 127.0.0.1,::1,172.16.0.0/12
volumes:
- /srv/zammad/storage:/opt/zammad/storage
depends_on:
zammad-postgresql:
condition: service_healthy
zammad-redis:
condition: service_healthy
zammad-memcached:
condition: service_healthy
services:
zammad-postgresql:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: zammad-db
restart: unless-stopped
environment:
POSTGRES_DB: zammad_production
POSTGRES_USER: zammad
POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
volumes:
- /srv/zammad/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zammad -d zammad_production"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
zammad-redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: zammad-redis
restart: unless-stopped
volumes:
- /srv/zammad/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
zammad-memcached:
image: memcached:1.6.45-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
container_name: zammad-memcached
restart: unless-stopped
command: memcached -m 256M
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
interval: 10s
retries: 12
zammad-railsserver:
<<: *zammad
container_name: zammad-railsserver
command: ["zammad-railsserver"]
healthcheck:
# The first boot loads a large schema, hence the long start period.
test: ["CMD", "curl", "-sf", "http://127.0.0.1:3000"]
interval: 30s
start_period: 240s
retries: 5
zammad-websocket:
<<: *zammad
container_name: zammad-websocket
command: ["zammad-websocket"]
zammad-scheduler:
<<: *zammad
container_name: zammad-scheduler
command: ["zammad-scheduler"]
zammad-nginx:
<<: *zammad
container_name: zammad-nginx
command: ["zammad-nginx"]
init: false
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8169.
- "127.0.0.1:8169:8080"
depends_on:
zammad-railsserver:
condition: service_healthyCaddyfilethe hostname and TLS24 lines
# Zammad · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.zammad.org/en/latest/install/docker-compose.html and
# https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed at this
# box; that hostname is also ZAMMAD_FQDN in .env.
<DOMAIN> {
# A helpdesk holds other people's names and complaints, so nothing here
# is framed, sniffed, or leaked through a referrer.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8169 is the loopback port compose publishes here, not open in the
# firewall. The Zammad nginx behind it routes /ws itself, so Caddy has
# one upstream.
reverse_proxy 127.0.0.1:8169
}install.shthe same install, no agent168 lines
#!/usr/bin/env bash
# Zammad · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=help.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.zammad.org/en/latest/install/docker-compose.html
# https://docs.zammad.org/en/latest/install/docker-compose/docker-compose-scenarios.html
# https://docs.zammad.org/en/latest/appendix/environment-variables.html
# https://docs.zammad.org/en/latest/prerequisites/software.html
#
# One secret is generated here, on this machine: the PostgreSQL password, which
# upstream otherwise ships as the word zammad. It goes into /srv/zammad/.env
# with mode 600 and is never printed.
#
# DOMAIN_HOST is also ZAMMAD_FQDN, the address Zammad writes into every link it
# builds.
#
# This script stops one step short of a usable install on purpose: only a human
# in a browser can work through the setup wizard that creates the administrator
# account. The closing summary says where, and what to run afterwards.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/zammad}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. help.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 6144 ] || die "only ${avail_mb} MB of RAM available; four Rails processes plus PostgreSQL want 6144 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# storage belongs to uid 1000 because that is the user the Zammad image runs as.
# postgres stays root-owned at 700: the PostgreSQL image chowns its own data
# directory on first start and refuses one that has been chowned already.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/redis"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/storage"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: it travels inside a connection string. Read it later
# with
# sudo grep POSTGRESQL_PASS /srv/zammad/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
ZAMMAD_FQDN=${DOMAIN_HOST}
ZAMMAD_HTTP_TYPE=https
POSTGRESQL_PASS=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-zammad"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and 8169, 5432, 6379 and 11211 are none of them -----
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8169, 5432, 6379 and 11211 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Migrate, then start --------------------------------------------------
#
# The init command creates the database, loads the schema, seeds it and writes
# the FQDN from .env into the settings table. It runs once here and again after
# every image update. The other containers wait for it, so skipping it looks
# exactly like a hung boot.
docker compose pull
echo "==> loading the schema; this prints a lot and takes minutes"
docker compose run --rm --user 0:0 zammad-railsserver zammad-init
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$code" = "200" ] && break
sleep 15
done
[ "${code:-}" = "200" ] || die "the site answered ${code:-nothing}. Check: docker compose logs --tail 40 zammad-railsserver"
# The API must refuse a call with no session. A helpdesk on the public internet
# that answers this one with data is an incident, not an install.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/users" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated call to /api/v1/users returned ${unauth}, not 401. Stop and investigate."
setup="$(curl -sS -H 'Content-Type: application/json' -d '{"query":"{systemSetupInfo{status}}"}' "https://${DOMAIN_HOST}/graphql" || true)"
printf '%s' "$setup" | grep -q '"status":"new"' \
|| die "Zammad reported ${setup}, not status new. A finished setup here means this is not a fresh install."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T zammad-postgresql pg_dump -U zammad -d zammad_production | gzip > "$APP_DIR/backups/zammad-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/zammad-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/zammad-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Zammad is answering at https://${DOMAIN_HOST}/
1. One step is left and only you can do it. Open https://${DOMAIN_HOST}
and press the button reading "Set up a new system" under the heading
"Welcome!", then work through the wizard. It creates the single
administrator account. Put that password in your password manager as
you type it: this install configures no mail, so there is no reset
link.
2. Then close the self-signup door Zammad ships open, and check it:
cd $APP_DIR
docker compose exec -T zammad-railsserver bundle exec rails r "Setting.set('user_create_account', false)"
curl -sS -H 'Content-Type: application/json' -d '{"query":"{applicationConfig{key value}}"}' https://${DOMAIN_HOST}/graphql | grep -q user_create_account && echo "signup OPEN" || echo "signup CLOSED"
That last line must print signup CLOSED before you hand the address to
anybody. Until it does, a stranger who finds the hostname can make
themselves an account here.
3. One secret lives in $APP_DIR/.env, mode 600, not printed here. No human
signs in with it. A database restored beside a different .env does not
open, which is why the config archive exists.
4. First backup written to $APP_DIR/backups: a database dump and a config
archive holding compose.yml, .env, storage/ and the Caddy site block.
The dump predates your administrator account, so re-run the two backup
commands from step 7 of the guide once the wizard is done. Both sit on
the same disk as the data, which is not a backup. Copy them somewhere
else tonight.
5. There is no Elasticsearch here, which upstream calls optional but
recommended. Search works over fewer fields than it would with one.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Zendesk.
- This is a stack, not an app. Seven containers, four of them the same Zammad image under different commands, plus PostgreSQL, Redis and memcached. On any given morning one of them can be the unhappy one, and you are the only person who will notice. The one nobody notices is the scheduler: the desk keeps answering pages perfectly while escalation clocks, triggers and notifications quietly stop firing.
- No search index. Upstream calls Elasticsearch optional but strongly recommended, and says plainly that without it search is degraded and very limited. This install leaves it out through upstream's own switch, because it wants a fourth gigabyte of RAM to itself. You can add it later; you will pay for it in memory and a reindex, and until you do, searching a year of ticket history is not the tool Zendesk gave you.
- No mail, and mail is what most people came for. A support address that turns email into tickets is Zendesk's core, and this install configures no SMTP and no IMAP: no inbound mail channel, no notification mail, no password-reset link. The web form, the agent interface and the customer portal all work without it. Adding mail is a separate day with a provider whose deliverability you now own.
- You own a PostgreSQL, and attachments are in it. Zammad's default storage setting keeps files in the database rather than on disk, so the dump is the whole of the data and it grows with every screenshot a customer sends. The .env beside it is the other half of the same backup, because a database restored beside a different password does not open.
- AGPL-3.0 with nothing held back, and a shorter feature list than the plan you are leaving. Every Zammad feature is in the release you install; what the upper Zendesk tiers sell that is not here at all is the AI copilot, workforce management, and the voice contact centre. The Zammad company sells hosting and support subscriptions, not a better build.
Where this came from
“This dependency is optional but strongly recommended! Zammad will work without it, but search performance will be degraded and the search will be very limited.”
- Upstream's own compose file declares ten services, and their documented scenario files switch off the Elasticsearch and the nightly backup containers, which is how this install gets to seven. source
- Upstream states a minimum of 2 CPU cores and 6 GB of RAM, plus another 4 GB if Elasticsearch runs on the same server, and declines to recommend any disk figure. source
- ELASTICSEARCH_ENABLED set to false is upstream's switch for running without Elasticsearch, RAILS_TRUSTED_PROXIES defaults to 127.0.0.1 and ::1, and the database password ships with the default value zammad. source
- Zammad seeds the user_create_account setting on, so a fresh install lets anyone who finds the hostname create their own account until an administrator turns it off. source
- One image runs as uid 1000 and dispatches on its first argument, which is what makes railsserver, websocket, scheduler, nginx and the one-off migration run the same container with different commands. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Zendesk?
Not Zendesk itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Zammad. A shared support desk with agents, groups, escalation clocks and a customer portal, on your own hostname, with nobody counting seats. The install is ongoing ops: 7 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 300 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Zendesk?
Zammad. A shared support desk with agents, groups, escalation clocks and a customer portal, on your own hostname, with nobody counting seats. The closest thing on this list to what Zendesk actually sells: tickets with owners, groups, states and escalation clocks, an agent interface built for a queue rather than a chat, a customer portal, triggers and schedulers, and a text module and knowledge base behind them. It is also the heaviest install here, seven containers and a six gigabyte floor, and the version this catalogue installs leaves out Elasticsearch and configures no mail, which is honestly two of the things you were paying for. Everything else is present and uncapped on agents. Also evaluated and ranked below it: Chatwoot. The prompt on this page installs Zammad only. Zammad is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Zendesk?
6144 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Zammad itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Zendesk Suite Team, $275/mo — $3,300 a year, 5 seats assumed.
How hard is it really?
ONGOING OPS — 3+ hours, then ongoing. The rule that produced that verdict: five or more containers. Five or more services is a stack. On any given morning one of them is unhappy, and you are the only person who is going to notice. The tier is derived from seven countable facts about the Zammad install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Zammad on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Zammad on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Only this computer can open the helpdesk, so nobody you would actually support can reach it, and every escalation clock and reminder stops running the moment the machine sleeps. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.