# Can I self-host Zendesk?

**YES, IF** — it's called Zammad. ONGOING OPS setup · ~5 hours to running · 6 GB RAM minimum · $275/mo you stop paying ($3,300/yr on the Suite Team plan, 5 seats assumed).

Zammad authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/zendesk/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below Zammad for this swap. The prompts above install Zammad only.

- **Chatwoot** — 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 page this mirrors: https://caniselfhostit.com/self-host/zendesk/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
