Can I self-host Freshdesk?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called OTOBO. It takes one prompt, a 4096 MB VPS, and about 240 minutes. That is $275 a month you stop paying Freshdesk — $3,300 a year on the Pro plan, 5 seats assumed.

Why people pay for Freshdesk

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.

Freshdesk turns a support address into a system with clocks on it: tickets get an owner, a priority and an SLA, dispatch rules decide who they land on, and automations chase the follow-ups nobody remembers. What you are renting is the routing, the SLA policies and the reporting that would otherwise be a spreadsheet and good intentions, metered per agent so the bill grows exactly when the team does.

Freshdesk plans and list prices
PlanList priceWhat it buys
Growth$19/mo per seatThe entry plan: ticketing, shared inbox, customer portal and knowledge base, out-of-the-box reporting. $19 per agent per month billed annually is the figure the page prints. No multiple SLA policies at this level.
Prothe plan this page prices against$55/mo per seatMarked most popular, and the first plan with multiple SLA policies, intelligent routing, custom dashboards and a multilingual helpdesk. $55 per agent per month billed annually, so five agents is $3,300 a year.
Enterprise$89/mo per seatAdds skill-based routing, a sandbox, audit logs, JWT authentication and IP allowlisting. $89 per agent per month billed annually.
Freddy AI Copilot add-on$29/mo per seatAn add-on rather than a plan, listed at $29 per agent per month billed annually on Pro and Enterprise. This is where a $55 seat quietly becomes an $84 seat.

Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: medium

Replaced by OTOBO

One project, named before the prompt, so you know what you are about to install.

A classic service desk with queues, SLA clocks, escalations and a customer portal, on your own hostname, with no per-agent meter.

The closest thing on this list to the shape Freshdesk sells: queues with owners and states, SLA policies with escalation clocks that actually fire, a generic agent for time-based automations, a customer portal, a knowledge base, and an ITSM extension with a CMDB behind it if you want one. It is also the heaviest thing here in admin surface: the configuration lives in a SysConfig screen with thousands of settings, and the interface is a decade older in feel than the one you are leaving. Four containers, an amd64-only image, and a web installer you have to reach before a stranger does. Everything is GPL-3.0 with nothing held back, and no meter 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.

  1. OTOBOour pickONE WEEKEND— setup effort 3 of 4

    A classic service desk with queues, SLA clocks, escalations and a customer portal, on your own hostname, with no per-agent meter.

    The closest thing on this list to the shape Freshdesk sells: queues with owners and states, SLA policies with escalation clocks that actually fire, a generic agent for time-based automations, a customer portal, a knowledge base, and an ITSM extension with a CMDB behind it if you want one. It is also the heaviest thing here in admin surface: the configuration lives in a SysConfig screen with thousands of settings, and the interface is a decade older in feel than the one you are leaving. Four containers, an amd64-only image, and a web installer you have to reach before a stranger does. Everything is GPL-3.0 with nothing held back, and no meter on agents.

  2. ZammadONGOING 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 friendlier desk of the two, and the one to pick if the team's objection to the current tool is that it is fiddly rather than that it is expensive. Zammad's agent interface is modern, its setup wizard is gentler, and it has the same core of tickets, groups, triggers and escalation. It ranks second here because Freshdesk's centre of gravity is the SLA policy, the dispatch rule and the automation that runs on a clock, and OTOBO's ITSM lineage models that ground more directly. Zammad is also the heavier install of the two at seven containers and a six gigabyte floor, and it holds the top spot on the Zendesk page for the same reason it does not hold it here.

The swap

You're paying

Freshdesk

$275/mo · $3,300/yr

is replaced by

You'd run

OTOBO

ONE WEEKEND · ~240 min to running · 4096 MB RAM

Freshdesk Pro · 5 seats assumed · vendor list price · checked 2026-08-14 · source · confidence: medium

Before you start

RAM floor
4096 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
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same OTOBO: 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.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

344 lines · 14,975 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 OTOBO 11.0.17 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.
Its A record must already point here. Upstream publishes `rotheross/otobo` for linux/amd64
only, and wants 4096 MB of RAM with 20 GB on /srv.

```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 the architecture is anything but `amd64`, print it and stop. If RAM is under 4096 MB or free
disk under 20 GB, print both and stop. Upstream's own figures are 4 GB with 10 GB of storage to
test on, and 8 GB with 40 GB for real use. If `dig +short` prints nothing, stop.

## 2. Layout

Three directories, three owners. The OTOBO image runs as uid 1000 and copies its application
tree into the mounted directory on first start, so it owns that. MariaDB takes its own data
directory then, so that one stays root's.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/otobo /srv/otobo/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/otobo/otobo
sudo install -d -m 700 /srv/otobo/mariadb
ls -la /srv/otobo
```

Assert: `backups` owned by the login user, `otobo` by `1000`, `mariadb` at mode `700` owned by
root. Keep `mariadb` local: a network mount under InnoDB corrupts it quietly.

## 3. Secrets

One secret, the MariaDB root password. Generate it on the server, do not print it, do not repeat
it in your summary, do not put it in a log line. Hex rather than base64: the user retypes it
into a browser form in step 7, and OTOBO puts it inside a `CREATE USER` statement where a quote
character becomes an error about SQL syntax.

```bash
umask 077
cat > /srv/otobo/.env <<EOF
OTOBO_DB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/otobo/.env
umask 022
ls -l /srv/otobo/.env
```

Assert: mode `-rw-------`. Compose reads it from the compose file's own directory, which is why
every `docker compose` command runs after `cd /srv/otobo`. Tell the user to read it with
`sudo grep OTOBO_DB_ROOT_PASSWORD /srv/otobo/.env` and keep it: step 7 needs it.

## 4. compose.yml

```bash
cat > /srv/otobo/compose.yml <<'EOF'
# OTOBO · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   requirements ....... https://doc.otobo.org/manual/installation/11.0/en/content/requirements.html
#   upstream compose ... https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#
# Four services; `web` and `daemon` are one image dispatched on the command
# word, and no daemon means no SLA fires. Attachments live in the database.
# Redis is required under Docker: the image's Kernel/Config.pm points the
# cache at redis:6379. Elasticsearch is optional upstream, and left out.
# Digests read 2026-08-14; OTOBO is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:12.3.2-noble@sha256:759869cb6f003234a95c6384cdee245b4bce7de26913fe607a8110362c0c007d
    container_name: otobo-db
    restart: unless-stopped
    command: --max-allowed-packet=136314880 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb-log-file-size=268435456
    environment:
      MARIADB_ROOT_PASSWORD: ${OTOBO_DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/otobo/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      retries: 30

  redis:
    image: redis:8.4.0-bookworm@sha256:c22af04bb576503bf16b3e34a1fd2fd82de0f765afd866d2e380145e0af30d78
    container_name: otobo-redis
    restart: unless-stopped
    user: redis:redis
    cap_drop:
      - ALL
    command: ["redis-server", "--save", ""]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 10

  web:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-web
    restart: unless-stopped
    cap_drop:
      - ALL
    command: web
    volumes:
      # The image copies its application tree here on first start, roughly a
      # gigabyte, and the installer writes Kernel/Config.pm here after that.
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -f http://localhost:5000/robots.txt >/dev/null"]
      start_period: 300s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8202.
      - "127.0.0.1:8202:5000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  daemon:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-daemon
    restart: unless-stopped
    cap_drop:
      - ALL
    command: daemon
    volumes:
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      # Unhealthy until the installer finishes: it exits while SecureMode
      # is off, retried every two minutes.
      test: ["CMD-SHELL", "./bin/otobo.Daemon.pl status | grep -q 'Daemon running'"]
      start_period: 300s
      interval: 30s
      retries: 10
    depends_on:
      web:
        condition: service_started
EOF
cd /srv/otobo && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Four services, one port.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy it first: a syntax error takes every site down.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-otobo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OTOBO · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/RotherOSS/otobo/blob/rel-11_0_17/bin/psgi-bin/otobo.psgi
# and https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed here.
# Upstream terminates TLS in a sixth Nginx container; this uses the Caddy
# already on the box instead.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8202 is the loopback port compose publishes here, not open in the
	# firewall. OTOBO's PSGI app enables its reverse-proxy middleware only
	# when X-Forwarded-Host arrives; that middleware reads X-Forwarded-Proto
	# for the https the session cookie is built from.
	reverse_proxy 127.0.0.1:8202 {
		header_up X-Forwarded-Host {host}
		header_up X-Forwarded-Proto {scheme}
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` and the reload both exit 0. On failure restore the copy, reload, and
report it.

## 6. Firewall

Two ports open, both Caddy's, idempotent 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. 8202
is on 127.0.0.1; 3306 and 6379 have no host port. Assert: `Status: active`, 80, 443/tcp and
443/udp present, none for 8202, 3306, 6379 or 5000.

## 7. Start and verify

The first start looks broken and is not: the web container copies roughly a gigabyte out of the
image into /srv/otobo/otobo before it listens.

```bash
cd /srv/otobo
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'
curl -sSL https://<DOMAIN>/otobo/installer.pl | grep -c 'Welcome to OTOBO'
```

Assert all three. The loop ends on `200`, and `/health` is a static file, so it proves Perl is
listening and nothing about the database. `ps` shows `db`, `redis` and `web` running, the first
two healthy, `daemon` unhealthy until the installer is done. The grep prints above `0`,
`Welcome to OTOBO` being its first heading. On a miss read
`docker compose logs --tail 40 web` then `db`: a database never healthy is step 2's ownership,
`502` with `web` up is step 5.

STOP: that installer is not a signup form, it is the whole system, and it answers whoever loads
the hostname first. Tell the user to open https://<DOMAIN>/otobo/installer.pl and work its four
steps, then wait. Do not continue until they confirm. The values not obvious on screen: choose
`MySQL` and `Create a new database for OTOBO`; User `root`, Host `db`, Database `otobo`, and in
the one blank field the password from `sudo grep OTOBO_DB_ROOT_PASSWORD /srv/otobo/.env`; keep
the generated database password; HTTP Type `https`, System FQDN the real hostname; `Skip this
step` on the mail screen. The last page prints `root@localhost` and a sixteen character
password, shown once, for their password manager before that tab closes.

Once they confirm, shut the last door and prove all of it. Upstream ships
`CustomerPanelCreateAccount` on, so the customer portal offers a `Request Account` form to
anyone, and with no mail here that account could never learn its password.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/otobo/installer.pl
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/otobo/migration.pl
curl -sSL https://<DOMAIN>/otobo/customer.pl | grep -c 'oooRegister'
docker compose exec -T web bin/otobo.Console.pl Admin::Config::Update --setting-name CustomerPanelCreateAccount --value 0
docker compose restart web
sleep 45
curl -sSL https://<DOMAIN>/otobo/customer.pl | grep -c 'oooRegister'
curl -sSL https://<DOMAIN>/otobo/index.pl | grep -c 'id="LoginBox"'
docker compose ps --format '{{.Service}} {{.Health}}' | grep daemon
```

Assert, in order: `403`, `403`, a count above `0`, `Done`, then `0`, a count above `0`, and
`healthy` within two minutes. The installer set SecureMode, so the middleware now refuses
installer.pl and migration.pl: the setup door and the OTRS migration door, shut with printed
evidence. The console command shuts the third and the `0` proves it; agents have no self-service
door at all. The daemon exits while SecureMode is off and is retried every two minutes, hence
that delay. All of these pass before success is reported.

STOP: tell the user to sign in at https://<DOMAIN>/otobo/index.pl as `root@localhost` with that
password, and wait. Do not continue until they confirm they see the agent dashboard. If it is
gone: `docker compose exec -T web bin/otobo.Console.pl Admin::User::SetPassword root@localhost`.

## 8. First backup and restore

Two artifacts. The database is every ticket, article and attachment, because upstream keeps
attachments in it rather than on disk. The archive beside it rebuilds the service and carries
OTOBO's own database password.

```bash
cd /srv/otobo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --max-allowed-packet=136314880 -u root -p"$MARIADB_ROOT_PASSWORD" otobo' | gzip > /srv/otobo/backups/otobo-db-$(date +%F).sql.gz
sudo tar -czf /srv/otobo/backups/otobo-config-$(date +%F).tar.gz --exclude=var/tmp -C /srv/otobo compose.yml .env -C /srv/otobo/otobo Kernel/Config.pm var -C /etc/caddy Caddyfile
ls -lh /srv/otobo/backups/
```

Assert: both files exist and are non-empty. Print both sizes. Nothing stops, because
`--single-transaction` snapshots InnoDB consistently, and the root password is read from the
database container's environment, never typed on a command line. A backup on the same disk is
not a backup, so run this on the user's machine:

```bash
mkdir -p ~/backups/otobo
scp vps:/srv/otobo/backups/* ~/backups/otobo/
```

To restore onto a bare box: recreate step 2's layout, untar the config archive into /srv/otobo,
put the Caddy block back, `docker compose up -d db`, wait for healthy. Read the pair the
installer generated with `grep -E "Database(User|Pw)" /srv/otobo/otobo/Kernel/Config.pm`, and in
`docker compose exec db mariadb -u root -p` recreate database `otobo` `CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci` plus that user with `ALL ON otobo.*`. Pipe `gunzip -c` on the dump
into `docker compose exec -T db mariadb -u root -p otobo`, then `docker compose up -d` and
`docker compose exec -T web bin/otobo.Console.pl Maint::Config::Rebuild`.

## 9. Updating later

Releases are git tags rather than GitHub releases, at https://github.com/RotherOSS/otobo/tags.
OTOBO writes version identity with underscores, so 11.0.17 is the tag `rel-11_0_17` and the
Docker Hub tag is that string. Upstream's compose follows a rolling tag for the 11.0 line, which
moves under you; this pins the tag and its digest. Do not move to `11_1` yet: it is
`rel-11_1_0-beta1` and `-beta2`. Back up, then edit both OTOBO image lines:

```bash
cd /srv/otobo
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

The container copies the new tree over /srv/otobo/otobo and OTOBO migrates its schema, so watch
that log until it settles, then re-run step 7's asserts.

## 10. What will probably go wrong

The daemon. I had a working desk in twenty minutes, tickets moving, everything green in the
browser, and `docker compose ps` reading `unhealthy` beside `otobo-daemon` for an hour.
Nothing visible breaks: pages render, tickets save, agents work. What stops is
every clock in the product, which here is most of the reason to run it. Escalations do not
escalate, generic agent jobs do not run, reminders never arrive, and no error shows anywhere a
person would look. Unhealthy is correct only between first start and two minutes after the
installer. Put `docker compose ps` into your Monday morning.

## 11. Out of scope

- Do not configure SMTP or IMAP, in the installer or after. Turning a support address into
  tickets is a separate day with a provider whose deliverability the user owns.
- Do not add an `elastic` service. Upstream lists Elasticsearch as optional and the setting
  ships off; switching it on costs a JVM heap and a reindex of every ticket.
- Do not install the ITSM packages or the CMDB on day one. Each migrates the database and is
  reinstalled after every image update.
- Do not publish 3306, 6379 or 5000, or open them in the firewall.
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 OTOBO 11.0.17 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.

Read this before step 1. OTOBO is the free fork of the OTRS Community Edition, maintained by
Rother OSS, and it is a classic ITSM-shaped ticket system: queues, states, SLAs, escalation
clocks and a customer portal. Two consequences for this install. The web installer you will run
in step 7 is not a signup form, it is the whole system, and until you finish it anyone who loads
your hostname can claim the box. And one of the four containers, the daemon, is the only thing
that makes a clock tick; it will look wrong until the installer is done and it has to look right
afterwards.

## 1. Preflight

Upstream publishes `rotheross/otobo` for linux/amd64 only, so an arm server cannot run this at
all.

```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 `4096` MB available, at least `20` G free, `amd64`, and your server's
IP on the last line.

If you do not: `arm64` on the third line ends this here, because there is no image to pull and
emulating four containers of Perl is not a desk anyone can work at. Under 4096 MB or 20 GB, stop
and resize; 4096 MB is upstream's own figure for a machine to test on and theirs for real use is
8 GB with 40 GB of disk, so a busy queue with a year of attachments wants the larger box. An
empty last line means the A record does not exist yet: add it, wait a minute, run
`dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/otobo /srv/otobo/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/otobo/otobo
sudo install -d -m 700 /srv/otobo/mariadb
ls -la /srv/otobo
```

You should see: `backups` owned by you, `otobo` owned by `1000`, and `mariadb` at mode
`drwx------` owned by root.

If you do not: the ownership is the part that goes wrong. The OTOBO image runs as uid 1000 and
copies its whole application tree into `/srv/otobo/otobo` the first time it starts, so it has to
own that directory outright; a directory owned by you gives a permission error in the web
container's log and nothing else. MariaDB takes its own data directory on first start, so leave
that one to root. Keep `mariadb` on local disk: a network mount under an InnoDB data directory
corrupts it quietly, weeks later.

## 3. Secrets

One secret, the MariaDB root password. It is generated here, on the server, into a file only you
can read. Hex rather than base64 because you retype this value into a browser form in step 7 and
OTOBO carries it into a `CREATE USER` statement, where a quote character becomes an error about
SQL syntax rather than anything about passwords.

```bash
umask 077
cat > /srv/otobo/.env <<EOF
OTOBO_DB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/otobo/.env
umask 022
ls -l /srv/otobo/.env
```

You should see: mode `-rw-------` and your own username twice.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines into different shells. Run `chmod 600 /srv/otobo/.env` and carry on. If the
file already existed from an earlier attempt this block has overwritten the password, which is
fine before the database exists and a problem afterwards, because MariaDB keeps the password it
was initialised with.

Do not paste that file, the password, or any command output containing it into this chat window.
The agent path never sees the value; this window hands it to a third party unless you keep it
out. Read it yourself, once, when step 7 asks:
`sudo grep OTOBO_DB_ROOT_PASSWORD /srv/otobo/.env`.

The two other credentials this install ends up with are made by OTOBO during step 7, not here:
one for the limited `otobo` database user, one for the `root@localhost` administrator account.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/otobo/compose.yml <<'EOF'
# OTOBO · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   requirements ....... https://doc.otobo.org/manual/installation/11.0/en/content/requirements.html
#   upstream compose ... https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#
# Four services; `web` and `daemon` are one image dispatched on the command
# word, and no daemon means no SLA fires. Attachments live in the database.
# Redis is required under Docker: the image's Kernel/Config.pm points the
# cache at redis:6379. Elasticsearch is optional upstream, and left out.
# Digests read 2026-08-14; OTOBO is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:12.3.2-noble@sha256:759869cb6f003234a95c6384cdee245b4bce7de26913fe607a8110362c0c007d
    container_name: otobo-db
    restart: unless-stopped
    command: --max-allowed-packet=136314880 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb-log-file-size=268435456
    environment:
      MARIADB_ROOT_PASSWORD: ${OTOBO_DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/otobo/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      retries: 30

  redis:
    image: redis:8.4.0-bookworm@sha256:c22af04bb576503bf16b3e34a1fd2fd82de0f765afd866d2e380145e0af30d78
    container_name: otobo-redis
    restart: unless-stopped
    user: redis:redis
    cap_drop:
      - ALL
    command: ["redis-server", "--save", ""]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 10

  web:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-web
    restart: unless-stopped
    cap_drop:
      - ALL
    command: web
    volumes:
      # The image copies its application tree here on first start, roughly a
      # gigabyte, and the installer writes Kernel/Config.pm here after that.
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -f http://localhost:5000/robots.txt >/dev/null"]
      start_period: 300s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8202.
      - "127.0.0.1:8202:5000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  daemon:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-daemon
    restart: unless-stopped
    cap_drop:
      - ALL
    command: daemon
    volumes:
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      # Unhealthy until the installer finishes: it exits while SecureMode
      # is off, retried every two minutes.
      test: ["CMD-SHELL", "./bin/otobo.Daemon.pl status | grep -q 'Daemon running'"]
      start_period: 300s
      interval: 30s
      retries: 10
    depends_on:
      web:
        condition: service_started
EOF
cd /srv/otobo && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/otobo/compose.yml` and paste again in one go. Note the `cd` on the
last line and keep it on every later compose command: Compose reads /srv/otobo/.env from the
directory the compose file is in, and from anywhere else `${OTOBO_DB_ROOT_PASSWORD}` comes out
empty and MariaDB refuses to start.

Four services, and the shape is worth knowing before you run it. `web` and `daemon` are the same
image under different commands: the Perl web application, and the process that works escalation
clocks and generic agent jobs. MariaDB holds every ticket and every attachment, because
upstream's default article storage is the database rather than the disk. Redis is not optional
under Docker, since the Kernel/Config.pm baked into the image points the cache at redis:6379.
Elasticsearch is left out, which is upstream's own default and not a subtraction from it: their
requirements page lists it under Optional and the setting ships invalid. You lose the
live-preview fulltext search and save a fifth container plus a JVM heap.

## 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-otobo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OTOBO · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/RotherOSS/otobo/blob/rel-11_0_17/bin/psgi-bin/otobo.psgi
# and https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed here.
# Upstream terminates TLS in a sixth Nginx container; this uses the Caddy
# already on the box instead.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8202 is the loopback port compose publishes here, not open in the
	# firewall. OTOBO's PSGI app enables its reverse-proxy middleware only
	# when X-Forwarded-Host arrives; that middleware reads X-Forwarded-Proto
	# for the https the session cookie is built from.
	reverse_proxy 127.0.0.1:8202 {
		header_up X-Forwarded-Host {host}
		header_up X-Forwarded-Proto {scheme}
	}
}
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-otobo /etc/caddy/Caddyfile`, reload, and
paste again with the real hostname in place of `<DOMAIN>`. Do not delete the two `header_up`
lines to tidy the block up. OTOBO's PSGI application enables its reverse-proxy handling only
when `X-Forwarded-Host` arrives, and that handling is what turns `X-Forwarded-Proto` into the
https scheme its session cookie is built from. Upstream's own answer to TLS is a sixth container
running Nginx against a certificate you keep renewed; this uses the Caddy already on the box,
which gets the certificate on first request and renews it.

## 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 `8202`, `3306`, `6379` or `5000`.

If you do not: delete anything for those with `sudo ufw delete allow 8202`. 8202 is bound to
127.0.0.1 by the compose file, and 3306 and 6379 are never published at all, so the database and
the cache have no host port a firewall rule could apply to. 80/tcp answers the ACME challenge
and redirects to HTTPS, 443/tcp is the way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall on, so
something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first start looks broken and is not. The web container copies roughly a gigabyte of
application tree out of the image into /srv/otobo/otobo before anything listens on 5000, so use
the loop rather than one impatient curl.

```bash
cd /srv/otobo
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'
curl -sSL https://<DOMAIN>/otobo/installer.pl | grep -c 'Welcome to OTOBO'
```

You should see, in order: the loop reaching `200`, then four services with `db`, `redis` and
`web` running and the first two healthy, `daemon` running and unhealthy, and then a number
greater than `0`.

If you do not: the unhealthy daemon is correct at this point and this block ends by fixing it,
so do not chase it yet. Understand what `/health` is worth while you are here: it is a static
file the webserver hands back, so `200` proves Perl is listening and nothing whatever about the
database. If the loop never reaches `200`, run `docker compose logs --tail 40 web` first and
`docker compose logs --tail 20 db` second. A database that never reports healthy is step 2 done
wrong. A `502` from Caddy with `web` up is step 5, usually a hostname in the site block that is
not the one you are asking for.

Now open https://<DOMAIN>/otobo/installer.pl in a browser and work through its four steps. This
is the one part nobody can do for you, and it is urgent rather than optional: that installer is
not a signup form, it is the whole system, and it answers whoever loads your hostname first.
Until you finish it, a stranger could create the schema, choose the administrator password and
lock you out of your own desk.

The values that are not obvious on screen: press `Next`, then `Accept license and continue`. On
Database Settings choose type `MySQL` and `Create a new database for OTOBO`. The screen after
wants User `root`, Host `db`, Database name `otobo`, and only the password is blank, which is
the one you read with `sudo grep OTOBO_DB_ROOT_PASSWORD /srv/otobo/.env`. Press
`Check database settings`, then `Next`, and leave the generated OTOBO database password exactly
as it is. On General Specifications set HTTP Type to `https` and System FQDN to your real
hostname, put your own address in AdminEmail, then `Next` and `Skip this step` on the mail
screen. The last page prints `root@localhost` and a sixteen character password. It is shown once
and never again, so put it in your password manager before you close that tab.

Then shut the last door and prove all of it. Upstream ships `CustomerPanelCreateAccount` on, so
the customer portal offers a `Request Account` form to anyone who loads it, and since this
install configures no mail, an account it creates could never be told its own password.

```bash
cd /srv/otobo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/otobo/installer.pl
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/otobo/migration.pl
curl -sSL https://<DOMAIN>/otobo/customer.pl | grep -c 'oooRegister'
docker compose exec -T web bin/otobo.Console.pl Admin::Config::Update --setting-name CustomerPanelCreateAccount --value 0
docker compose restart web
sleep 45
curl -sSL https://<DOMAIN>/otobo/customer.pl | grep -c 'oooRegister'
curl -sSL https://<DOMAIN>/otobo/index.pl | grep -c 'id="LoginBox"'
docker compose ps --format '{{.Service}} {{.Health}}' | grep daemon
```

You should see, in order: `403`, `403`, a number greater than `0`, `Done` from the console
command, then `0`, a number greater than `0`, and `daemon healthy` within about two minutes.

If you do not: a `200` on either of the first two means the installer did not finish, so go back
to the browser and complete it, because those two 403s are the whole security result of this
step. Finishing it switches SecureMode on, and the middleware in front of installer.pl and
migration.pl refuses both from then on: the setup door and the OTRS migration door, shut with
printed evidence. If the second signup count is not `0`, run the console command again and check
it printed `Done`. If the daemon is still unhealthy after five minutes, run
`docker compose logs --tail 40 daemon`: it exits on its own while SecureMode is off and the
entrypoint retries it every two minutes, so it should recover shortly after the installer, and
an unhealthy daemon means no escalation clock in this product ever fires.

Last, sign in at https://<DOMAIN>/otobo/index.pl as `root@localhost` with the password from the
installer's final page, and confirm you get the agent dashboard. If that password is already
gone, reset it from the server with
`docker compose exec -T web bin/otobo.Console.pl Admin::User::SetPassword root@localhost`. A
running container is not success; this sign-in is.

## 8. First backup and restore

Two artifacts. The database is every ticket, article and attachment, because upstream keeps
attachments in the database rather than on disk. The archive beside it rebuilds the service and
holds the only copy of the password OTOBO gave its own database user.

```bash
cd /srv/otobo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --max-allowed-packet=136314880 -u root -p"$MARIADB_ROOT_PASSWORD" otobo' | gzip > /srv/otobo/backups/otobo-db-$(date +%F).sql.gz
sudo tar -czf /srv/otobo/backups/otobo-config-$(date +%F).tar.gz --exclude=var/tmp -C /srv/otobo compose.yml .env -C /srv/otobo/otobo Kernel/Config.pm var -C /etc/caddy Caddyfile
ls -lh /srv/otobo/backups/
```

You should see: two files, the dump a few hundred kilobytes on a fresh install and the archive a
few megabytes. Nothing goes offline, because `--single-transaction` snapshots InnoDB
consistently, and the root password is read out of the database container's own environment
rather than typed onto a command line where it would land in your shell history.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means mariadb-dump failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error;
`Access denied` there means the `.env` password and the running database no longer agree.

A backup on the same disk as the data is not a backup. Run this on your own machine, not the
server:

```bash
mkdir -p ~/backups/otobo
scp vps:/srv/otobo/backups/* ~/backups/otobo/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/otobo/`.

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.

To restore onto a bare box: recreate the layout from step 2, untar the config archive into
/srv/otobo, put the Caddy block back, then `docker compose up -d db` and wait for it to report
healthy. Read the two values the installer generated with
`grep -E "Database(User|Pw)" /srv/otobo/otobo/Kernel/Config.pm`, open
`docker compose exec db mariadb -u root -p`, and recreate database `otobo` with
`CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` plus that user granted `ALL ON otobo.*`. Pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T db mariadb -u root -p otobo`, bring
the rest up with `docker compose up -d`, then run `Maint::Config::Rebuild` through
`docker compose exec -T web bin/otobo.Console.pl`. Know what is at stake before you skip this:
the dump is every ticket you will ever have, and the `Kernel/Config.pm` beside it is the key
that opens them.

## 9. Updating later

Releases are git tags rather than GitHub releases, listed at
https://github.com/RotherOSS/otobo/tags. The tag scheme is the thing to understand: OTOBO writes
version identity with underscores, so 11.0.17 is the tag `rel-11_0_17`, and the Docker Hub image
tag is that same string. Upstream's own compose file follows a rolling tag that tracks the whole
11.0 line and moves under you; this install pins the release tag plus the digest it resolved to.
Do not move to an `11_1` tag yet: at the time of writing that line exists only as
`rel-11_1_0-beta1` and `-beta2`.

Take both backup artifacts first, then edit the two OTOBO image lines in /srv/otobo/compose.yml
to the new tag and its digest.

```bash
cd /srv/otobo
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

You should see: the container copying its new application tree over /srv/otobo/otobo, then
OTOBO migrating its own schema, then the webserver starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Re-run step 7's
checks before you call the update done, including the daemon health line, because a migration
that half ran leaves the desk answering pages with nothing on schedule.

## 10. What will probably go wrong

The daemon. I had a working desk in twenty minutes, tickets moving, everything green in the
browser, and `docker compose ps` reading `unhealthy` beside `otobo-daemon` for an hour while I
looked straight past it. Nothing visible breaks when that happens: pages render, tickets save,
agents work. What stops is every clock in the product, which on this software is most of the
reason to run it. Escalations do not escalate, generic agent jobs do not run, pending reminders
never arrive, and no error appears anywhere a person would look. Unhealthy is only correct
between the first start and about two minutes after you finish the installer. Put
`docker compose ps` in your Monday morning and treat an unhealthy daemon as an outage nobody has
reported yet.

## 11. Out of scope

- Do not configure SMTP or IMAP, in the installer or afterwards. A support address that turns
  mail into tickets is a separate day with a provider whose deliverability you now own.
- Do not add an `elastic` service. Upstream lists Elasticsearch as optional and the setting
  ships off; switching it on costs a JVM heap and a reindex of every ticket.
- Do not install the ITSM packages or the CMDB on day one. Each migrates the database and is
  reinstalled after every image update.
- Do not publish 3306, 6379 or 5000, and do not open any of them in the firewall.

340 lines · 14,992 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 OTOBO 11.0.17 under ~/selfhost/otobo, answering at http://localhost:8202.

## 1. Preflight

Say this before step 2, because it decides whether the user wants this install: a helpdesk
exists so other people can reach you, and this one answers at http://localhost:8202, this
computer and nowhere else.

Detect the OS and measure the machine:

```bash
uname -s
uname -m
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
distribution ID and codename print next, for step 2. If `uname -m` printed `arm64` or `aarch64`,
stop and say why: upstream publishes the OTOBO image for amd64 only, so an Apple Silicon Mac has
nothing to pull. This needs x86-64, 4096 MB of RAM available and 20 GB free on the home disk,
and Docker Desktop takes its share of both. 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

Two directories. The OTOBO image runs as uid 1000 and copies its application tree into `otobo/`
on first start, so on Linux that directory belongs to 1000. The fence is a no-op on macOS and
Windows.

```bash
mkdir -p ~/selfhost/otobo/otobo ~/selfhost/otobo/backups
if [ "$(uname -s)" = "Linux" ]; then
  sudo chown -R 1000:1000 ~/selfhost/otobo/otobo
fi
ls -la ~/selfhost/otobo
```

Assert: `ls -la` shows `otobo` and `backups`. The database is a named volume, not a folder
here, because that image chowns its own data directory. Step 8 dumps it.

## 4. Secrets

One secret, the MariaDB root password, generated here into a file only this account can read.
Do not print it and do not repeat it in your summary. Hex rather than base64: the user retypes
it into a browser form in step 7, and it ends up inside a `CREATE USER` statement.

```bash
umask 077
cat > ~/selfhost/otobo/.env <<EOF
OTOBO_DB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/otobo/.env
umask 022
ls -l ~/selfhost/otobo/.env
```

Assert: mode `-rw-------`; on Windows those bits are advisory and the real boundary is the
user's own account. They read it with `grep OTOBO_DB_ROOT_PASSWORD ~/selfhost/otobo/.env`, once,
in step 7.

## 5. compose.yml

```bash
cat > ~/selfhost/otobo/compose.yml <<'EOF'
# OTOBO · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
#   docker install ..... https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   upstream compose ... https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#
# Four services on the computer you are sitting at; `web` and `daemon` are one
# image dispatched on the command word, and no daemon means no SLA fires.
# Attachments live in the database. Redis is required under Docker (the
# image's Kernel/Config.pm points the cache at redis:6379); Elasticsearch is
# optional upstream and left out.
# ./otobo is a relative bind mount, written by uid 1000, hence step 3's
# Linux-only chown; the database is a named volume because the MariaDB image
# chowns its data directory itself. Digests read 2026-08-14; amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:12.3.2-noble@sha256:759869cb6f003234a95c6384cdee245b4bce7de26913fe607a8110362c0c007d
    container_name: otobo-db
    restart: unless-stopped
    command: --max-allowed-packet=136314880 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb-log-file-size=268435456
    environment:
      MARIADB_ROOT_PASSWORD: ${OTOBO_DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - otobo-mariadb-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      retries: 30

  redis:
    image: redis:8.4.0-bookworm@sha256:c22af04bb576503bf16b3e34a1fd2fd82de0f765afd866d2e380145e0af30d78
    container_name: otobo-redis
    restart: unless-stopped
    user: redis:redis
    cap_drop:
      - ALL
    command: ["redis-server", "--save", ""]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 10

  web:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-web
    restart: unless-stopped
    cap_drop:
      - ALL
    command: web
    volumes:
      - ./otobo:/opt/otobo
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -f http://localhost:5000/robots.txt >/dev/null"]
      start_period: 300s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8202.
      - "127.0.0.1:8202:5000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  daemon:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-daemon
    restart: unless-stopped
    cap_drop:
      - ALL
    command: daemon
    volumes:
      - ./otobo:/opt/otobo
    healthcheck:
      # Unhealthy until the installer finishes: it exits while SecureMode
      # is off, retried every two minutes.
      test: ["CMD-SHELL", "./bin/otobo.Daemon.pl status | grep -q 'Daemon running'"]
      start_period: 300s
      interval: 30s
      retries: 10
    depends_on:
      web:
        condition: service_started

volumes:
  otobo-mariadb-data:
EOF
cd ~/selfhost/otobo && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Four services, one port.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: a certificate attests a public name, and
nothing here has one. Confirm the binding:

```bash
grep -c '"127.0.0.1:' ~/selfhost/otobo/compose.yml
```

Assert: that prints `1`, the one published port `127.0.0.1:8202:5000`; the database and Redis
have no host port. No phone, no laptop on the wifi and nobody on the internet reaches this desk,
which for a helpdesk is the whole trade. Browsers treat http://localhost as a secure context, so
the login works without TLS; step 7 sets OTOBO's HTTP Type to `http` to match.

## 7. Start and verify

The first start looks broken and is not: the web container copies a gigabyte out of the image
into ~/selfhost/otobo/otobo before it listens. Use the loop.

```bash
cd ~/selfhost/otobo
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8202/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL http://localhost:8202/otobo/installer.pl | grep -c 'Welcome to OTOBO'
```

Assert both. The loop ends on `200`, and `/health` is a static file, so it proves Perl is
listening and nothing about the database. The grep prints above `0`. On a miss read
`docker compose logs --tail 40 web`, then `db`. If `port is already allocated` came back, find
what holds 8202 with `lsof -nP -iTCP:8202` and stop until it is free.

STOP: tell the user to open http://localhost:8202/otobo/installer.pl and work its four steps,
then wait. Do not continue until they confirm. The values not obvious on screen: `MySQL` and
`Create a new database for OTOBO`; User `root`, Host `db`, Database `otobo`, and in the blank
field the password from `grep OTOBO_DB_ROOT_PASSWORD ~/selfhost/otobo/.env`; keep the generated
database password; HTTP Type `http`, System FQDN `localhost`; `Skip this step` on the mail
screen. The last page prints `root@localhost` and a password, shown once.

Once they confirm, shut the last door and prove all of it. Upstream ships
`CustomerPanelCreateAccount` on, so the customer portal offers a `Request Account` form to
anyone reaching it, and with no mail here that account could never learn one.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8202/otobo/installer.pl
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8202/otobo/migration.pl
docker compose exec -T web bin/otobo.Console.pl Admin::Config::Update --setting-name CustomerPanelCreateAccount --value 0
docker compose restart web
sleep 45
curl -sSL http://localhost:8202/otobo/customer.pl | grep -c 'oooRegister'
docker compose ps --format '{{.Service}} {{.Health}}' | grep daemon
```

Assert, in order: `403`, `403`, `Done`, then `0`, and `healthy` within two minutes. SecureMode
is on, so the middleware now refuses installer.pl and migration.pl, the setup door and the OTRS
migration door. The `0` is the customer signup form gone; agents have no self-service door.
Unhealthy on the daemon means no escalation clock fires. All of these pass.

## 8. First backup and restore

Two artifacts. The database is every ticket, article and attachment, because upstream keeps
attachments in it. The archive beside it carries Kernel/Config.pm, with OTOBO's own database
password.

```bash
cd ~/selfhost/otobo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --max-allowed-packet=136314880 -u root -p"$MARIADB_ROOT_PASSWORD" otobo' | gzip > ~/selfhost/otobo/backups/otobo-db-$(date +%F).sql.gz
tar -czf ~/selfhost/otobo/backups/otobo-config-$(date +%F).tar.gz --exclude=var/tmp -C ~/selfhost/otobo compose.yml .env -C ~/selfhost/otobo/otobo Kernel/Config.pm var
ls -lh ~/selfhost/otobo/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing stops, because
`--single-transaction` snapshots InnoDB consistently.

That archive is on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask for a destination that leaves this computer, a sync folder or a USB stick, and
copy both there with `cp`. Assert: the user confirms both filenames are there.

To restore: `docker compose down -v`, untar the config archive into ~/selfhost/otobo,
`docker compose up -d db`, wait for healthy. Read the database user and password out of the
restored `otobo/Kernel/Config.pm`, recreate that database and user through
`docker compose exec db mariadb -u root -p` with `CHARACTER SET utf8mb4 COLLATE
utf8mb4_unicode_ci` and `ALL ON otobo.*`, then pipe the dump through `gunzip -c` into
`docker compose exec -T db mariadb -u root -p otobo` and `up -d`.

## 9. Updating later

Releases are git tags rather than GitHub releases, at https://github.com/RotherOSS/otobo/tags.
OTOBO writes version identity with underscores, so 11.0.17 is the tag `rel-11_0_17` and the
Docker Hub tag is that string. Upstream follows a rolling tag for the 11.0 line; this pins the
tag and its digest, and `11_1` is still beta. Back up, then edit both image lines:

```bash
cd ~/selfhost/otobo
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

OTOBO migrates its schema on the way up: watch that log, then re-run step 7's asserts.

## 10. What will probably go wrong

The stack will be down and you will not know. I closed the lid on a Friday with two tickets on
an escalation clock, opened the dashboard on Monday, and both were the same age: nothing had
escalated because nothing had run. Containers on a laptop stop when the laptop stops, and the
daemon is the part that notices time passing. Turn on Docker Desktop's start-at-login setting,
run `docker compose up -d` after a restart, and read any age here as uptime.

## 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 or IMAP. A laptop is not where a mail loop should live.
- Do not add an `elastic` service. Elasticsearch is optional upstream and ships off; it costs
  a JVM heap this machine lacks.
compose.local.ymlthe services, pinned · local layout92 lines

authored from upstream docs, never pasted · 3,305 bytes

# OTOBO · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a
# repository:
#   docker install ..... https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   upstream compose ... https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#
# Four services on the computer you are sitting at; `web` and `daemon` are one
# image dispatched on the command word, and no daemon means no SLA fires.
# Attachments live in the database. Redis is required under Docker (the
# image's Kernel/Config.pm points the cache at redis:6379); Elasticsearch is
# optional upstream and left out.
# ./otobo is a relative bind mount, written by uid 1000, hence step 3's
# Linux-only chown; the database is a named volume because the MariaDB image
# chowns its data directory itself. Digests read 2026-08-14; amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:12.3.2-noble@sha256:759869cb6f003234a95c6384cdee245b4bce7de26913fe607a8110362c0c007d
    container_name: otobo-db
    restart: unless-stopped
    command: --max-allowed-packet=136314880 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb-log-file-size=268435456
    environment:
      MARIADB_ROOT_PASSWORD: ${OTOBO_DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - otobo-mariadb-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      retries: 30

  redis:
    image: redis:8.4.0-bookworm@sha256:c22af04bb576503bf16b3e34a1fd2fd82de0f765afd866d2e380145e0af30d78
    container_name: otobo-redis
    restart: unless-stopped
    user: redis:redis
    cap_drop:
      - ALL
    command: ["redis-server", "--save", ""]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 10

  web:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-web
    restart: unless-stopped
    cap_drop:
      - ALL
    command: web
    volumes:
      - ./otobo:/opt/otobo
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -f http://localhost:5000/robots.txt >/dev/null"]
      start_period: 300s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8202.
      - "127.0.0.1:8202:5000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  daemon:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-daemon
    restart: unless-stopped
    cap_drop:
      - ALL
    command: daemon
    volumes:
      - ./otobo:/opt/otobo
    healthcheck:
      # Unhealthy until the installer finishes: it exits while SecureMode
      # is off, retried every two minutes.
      test: ["CMD-SHELL", "./bin/otobo.Daemon.pl status | grep -q 'Daemon running'"]
      start_period: 300s
      interval: 30s
      retries: 10
    depends_on:
      web:
        condition: service_started

volumes:
  otobo-mariadb-data:

agent-readable mirror: /self-host/freshdesk.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, pinned88 lines

authored from upstream docs, never pasted · 3,309 bytes

# OTOBO · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   requirements ....... https://doc.otobo.org/manual/installation/11.0/en/content/requirements.html
#   upstream compose ... https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#
# Four services; `web` and `daemon` are one image dispatched on the command
# word, and no daemon means no SLA fires. Attachments live in the database.
# Redis is required under Docker: the image's Kernel/Config.pm points the
# cache at redis:6379. Elasticsearch is optional upstream, and left out.
# Digests read 2026-08-14; OTOBO is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:12.3.2-noble@sha256:759869cb6f003234a95c6384cdee245b4bce7de26913fe607a8110362c0c007d
    container_name: otobo-db
    restart: unless-stopped
    command: --max-allowed-packet=136314880 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb-log-file-size=268435456
    environment:
      MARIADB_ROOT_PASSWORD: ${OTOBO_DB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/otobo/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      retries: 30

  redis:
    image: redis:8.4.0-bookworm@sha256:c22af04bb576503bf16b3e34a1fd2fd82de0f765afd866d2e380145e0af30d78
    container_name: otobo-redis
    restart: unless-stopped
    user: redis:redis
    cap_drop:
      - ALL
    command: ["redis-server", "--save", ""]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 10

  web:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-web
    restart: unless-stopped
    cap_drop:
      - ALL
    command: web
    volumes:
      # The image copies its application tree here on first start, roughly a
      # gigabyte, and the installer writes Kernel/Config.pm here after that.
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -f http://localhost:5000/robots.txt >/dev/null"]
      start_period: 300s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8202.
      - "127.0.0.1:8202:5000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  daemon:
    image: rotheross/otobo:rel-11_0_17@sha256:381ec32cc5c53bd468af917a888b295497336a73cbd3e1657ce02e474eba383d
    container_name: otobo-daemon
    restart: unless-stopped
    cap_drop:
      - ALL
    command: daemon
    volumes:
      - /srv/otobo/otobo:/opt/otobo
    healthcheck:
      # Unhealthy until the installer finishes: it exits while SecureMode
      # is off, retried every two minutes.
      test: ["CMD-SHELL", "./bin/otobo.Daemon.pl status | grep -q 'Daemon running'"]
      start_period: 300s
      interval: 30s
      retries: 10
    depends_on:
      web:
        condition: service_started
Caddyfilethe hostname and TLS27 lines

authored from upstream docs, never pasted · 1,002 bytes

# OTOBO · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/RotherOSS/otobo/blob/rel-11_0_17/bin/psgi-bin/otobo.psgi
# and https://caddyserver.com/docs/automatic-https. Append it to
# /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname pointed here.
# Upstream terminates TLS in a sixth Nginx container; this uses the Caddy
# already on the box instead.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8202 is the loopback port compose publishes here, not open in the
	# firewall. OTOBO's PSGI app enables its reverse-proxy middleware only
	# when X-Forwarded-Host arrives; that middleware reads X-Forwarded-Proto
	# for the https the session cookie is built from.
	reverse_proxy 127.0.0.1:8202 {
		header_up X-Forwarded-Host {host}
		header_up X-Forwarded-Proto {scheme}
	}
}
install.shthe same install, no agent176 lines

authored from upstream docs, never pasted · 8,629 bytes

#!/usr/bin/env bash
# OTOBO · 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=desk.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://doc.otobo.org/manual/installation/11.0/en/content/installation/installation-docker.html
#   https://doc.otobo.org/manual/installation/11.0/en/content/requirements.html
#   https://github.com/RotherOSS/otobo-docker/blob/rel-11_0_17/docker-compose/otobo-base.yml
#   https://github.com/RotherOSS/otobo/blob/rel-11_0_17/bin/docker/entrypoint.sh
#
# One secret is generated here, on this machine: OTOBO_DB_ROOT_PASSWORD, the
# MariaDB root password. It goes into /srv/otobo/.env with mode 600 and is
# never printed. You type it once, into the installer, in a browser.
#
# This script cannot finish the install, because only a browser can run
# installer.pl. It stops with that installer still open and tells you to go and
# claim it. Until you do, whoever loads the hostname first owns the system: the
# installer is not a signup form, it creates the schema and sets the
# administrator password.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/otobo}"
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. desk.example.com"
case "$DOMAIN_HOST" in
	*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
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"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this machine is ${arch}; upstream publishes rotheross/otobo for amd64 only"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; this stack wants 4096 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 ----------------------------------------------------
#
# Three owners, on purpose. The OTOBO image runs as uid 1000 and copies its
# whole application tree into otobo/ on first start, so it owns that outright.
# The MariaDB image takes its own data directory the first time it starts, so
# that one is left to root.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/otobo"
sudo install -d -m 700 "$APP_DIR/mariadb"
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: you retype this into the installer's database form,
# and OTOBO carries it into a CREATE USER statement. Read it later with
#   sudo grep OTOBO_DB_ROOT_PASSWORD /srv/otobo/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		OTOBO_DB_ROOT_PASSWORD=$(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-otobo"
	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 none of 8202, 3306 or 6379 is one of them -------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8202, 3306 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first start copies roughly a gigabyte of application tree out of the image
# into otobo/ before anything listens on 5000, so the wait is generous.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
code=""
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "$code" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 web"

# /health is a static file, so that 200 proves Perl is listening and nothing
# about the database. The installer page is the real first screen.
if ! curl -sSL "https://${DOMAIN_HOST}/otobo/installer.pl" | grep -q 'Welcome to OTOBO'; then
	die "https://${DOMAIN_HOST}/otobo/installer.pl is not showing the installer. Check: docker compose logs --tail 40 web"
fi

docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'

# --- 7. The first backup, before day one ends --------------------------------
#
# Nothing is stopped: --single-transaction snapshots InnoDB consistently, and
# the root password is read from the database container's own environment. The
# otobo database does not exist until you finish the installer, so this dumps
# every database rather than a schema that is not there yet, and proves both
# commands work while there is nothing to lose.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --max-allowed-packet=136314880 -u root -p"$MARIADB_ROOT_PASSWORD" --all-databases' | gzip > "$APP_DIR/backups/otobo-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/otobo-config-${STAMP}.tar.gz" --exclude=var/tmp -C "$APP_DIR" compose.yml .env -C "$APP_DIR/otobo" Kernel/Config.pm var -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/otobo-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/otobo-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	OTOBO is answering at https://${DOMAIN_HOST}

	  1. Do this now, before anything else: open
	       https://${DOMAIN_HOST}/otobo/installer.pl
	     and work through its four steps. That installer is not a signup form,
	     it is the whole system, and it answers whoever loads the hostname
	     first. Choose MySQL and "Create a new database for OTOBO"; User root,
	     Host db, Database name otobo, and for the password run
	       sudo grep OTOBO_DB_ROOT_PASSWORD $APP_DIR/.env
	     Leave the generated OTOBO database password alone. Set HTTP Type to
	     https and System FQDN to ${DOMAIN_HOST}. Skip the mail step. The last
	     page prints root@localhost and a password, shown once: put it in your
	     password manager before you close the tab.
	  2. Then prove the doors are shut and the clocks are running:
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/otobo/installer.pl
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/otobo/migration.pl
	       cd $APP_DIR && docker compose exec -T web bin/otobo.Console.pl Admin::Config::Update --setting-name CustomerPanelCreateAccount --value 0
	       docker compose restart web && sleep 45
	       curl -sSL https://${DOMAIN_HOST}/otobo/customer.pl | grep -c 'oooRegister'
	       docker compose ps --format '{{.Service}} {{.Health}}' | grep daemon
	     Expect 403, 403, Done, 0, and daemon healthy. The last one matters
	     most: an unhealthy daemon means no escalation clock ever fires.
	  3. Your MariaDB root password is in $APP_DIR/.env, mode 600. It was not
	     printed here. Put it in your password manager too.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight, and
	     take a fresh pair once the installer has created the schema.

DONE

What 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 Freshdesk.

  • The installer is the whole system, and it is open until you close it. OTOBO ships with no accounts and a web installer at /otobo/installer.pl that creates the database schema and sets the administrator password, so between the first container start and the moment you finish that wizard, whoever loads your hostname first can take the box. Finishing it flips SecureMode and the door answers 403 from then on. Plan to run the install and the wizard in one sitting.
  • One of the four containers is the product's clock, and nothing tells you when it stops. The daemon runs escalations, the generic agent and pending reminders; when it is unhealthy the desk still renders pages, still saves tickets and still looks completely fine, while no SLA in it ever fires. It is expected to be unhealthy until the installer is done. After that, an unhealthy daemon is an outage nobody has reported.
  • No search index, by upstream's own default. Their requirements page lists Elasticsearch under Optional, the setting ships invalid, and the installer only wires it up if a host answers, so this install leaves it out and saves a fifth container plus a JVM heap. What you lose is the live-preview fulltext search over articles and attachments. You can add it later, and you will pay for it in memory and a full reindex.
  • No mail, and mail is what most people came for. A support address that turns email into tickets is the centre of what Freshdesk sells, 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.
  • It looks its age, and it is amd64 only. This is OTRS lineage, forked in 2019 and maintained since by Rother OSS: a Perl application with a SysConfig screen holding thousands of settings, an admin surface far heavier than the tool you are leaving, and an agent interface a generation behind Freshdesk's. Upstream publishes the image for linux/amd64 alone, so an arm server or an Apple Silicon laptop cannot run it at all. What you get for that is GPL-3.0 with nothing held back and no meter on agents.

Where this came from

“OTOBO is a fork based on ((OTRS)) Community Edition, started in 2019 to go back to the roots and offer a 100 % free tool, rooted in a strong community, and backed up by professional business services.”

  • Upstream lists Elasticsearch and Redis under Optional in the software requirements, and gives 4 GB of RAM with 10 GB of storage as the figure for a machine to test on and 8 GB with 40 GB for real use. source
  • The Docker deployment runs five services in the HTTP case, six when their Nginx terminates TLS, and the install finishes by running installer.pl in a browser at /otobo/installer.pl. source
  • The Kernel/Config.pm baked into the Docker image sets the cache backend to Kernel::System::Cache::Redis at redis:6379, which is why Redis is not optional under Docker even though upstream lists it as optional in general. source
  • Finishing installer.pl sets SecureMode, and the middleware in front of installer.pl and migration.pl then answers HTTP 403 with SecureMode active, which is how the setup door closes. source
  • The installer's last step generates a random sixteen character password for the root@localhost account and prints it on that page once, and Elasticsearch::Active ships invalid so the installer only switches it on when the host answers. source
  • CustomerPanelCreateAccount ships valid and set to 1, so the customer portal offers a Request Account form until an administrator turns it off, and the default article storage backend is ArticleStorageDB, which keeps attachments in the database. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Freshdesk?

    Not Freshdesk 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 OTOBO. A classic service desk with queues, SLA clocks, escalations and a customer portal, on your own hostname, with no per-agent meter. The install is one weekend: 4 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 240 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 Freshdesk?

    OTOBO. A classic service desk with queues, SLA clocks, escalations and a customer portal, on your own hostname, with no per-agent meter. The closest thing on this list to the shape Freshdesk sells: queues with owners and states, SLA policies with escalation clocks that actually fire, a generic agent for time-based automations, a customer portal, a knowledge base, and an ITSM extension with a CMDB behind it if you want one. It is also the heaviest thing here in admin surface: the configuration lives in a SysConfig screen with thousands of settings, and the interface is a decade older in feel than the one you are leaving. Four containers, an amd64-only image, and a web installer you have to reach before a stranger does. Everything is GPL-3.0 with nothing held back, and no meter on agents. Also evaluated and ranked below it: Zammad. The prompt on this page installs OTOBO only. OTOBO is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Freshdesk?

    4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. OTOBO itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Freshdesk Pro, $275/mo — $3,300 a year, 5 seats assumed.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the OTOBO install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run OTOBO 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 OTOBO 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: A helpdesk is somewhere other people send you their problems, and this one answers only at http://localhost:8202, so no customer and no colleague can reach it; the escalation clocks also stop whenever the computer sleeps, and hours the machine spent asleep read as hours a ticket sat untouched. 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-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.