# Can I self-host Intercom?

**YES, BUT** — it's called Chatwoot. ONE WEEKEND setup · ~4 hours to running · 4 GB RAM minimum · $195/mo you stop paying ($2,340/yr on the Essential plan, 5 seats assumed).

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

## 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 Chatwoot 4.16.2, community edition, 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.
Say this when you ask: `<DOMAIN>` becomes `FRONTEND_URL`, baked into the chat widget snippet they
paste on their site and into every link Chatwoot sends. Its A record must point at this server.

Chatwoot needs 4096 MB of RAM available and 20 GB free on /srv. Upstream states 4 GB as the
minimum and sizes that at up to 10,000 conversations a day. All three images publish amd64 and
arm64. Measure all four first:

```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 4096 MB or free disk is under 20 GB, print both numbers and stop. Rails
and Sidekiq each hold the whole application in memory, and the OOM killer arrives mid-migration.
If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/chatwoot /srv/chatwoot/backups /srv/chatwoot/storage /srv/chatwoot/redis
sudo install -d -m 700 /srv/chatwoot/postgres
ls -la /srv/chatwoot
```

Assert: `ls -la` shows `backups`, `storage` and `redis` owned by the login user, and `postgres`
at mode `700` owned by root. The PostgreSQL image chowns its own data directory on first start,
so that one is left alone. `storage` is where customer attachments land.

## 3. Secrets

Three secrets: the Rails key that signs cookies and sessions, the PostgreSQL password and the
Redis password. Generate all three on the server. Do not print any of them, do not repeat them
in your summary, and do not put them in any log line. Hex rather than base64, because upstream
asks for an alphanumeric value on the first and the others ride inside connection strings.

```bash
umask 077
cat > /srv/chatwoot/.env <<EOF
FRONTEND_URL=https://<DOMAIN>
SECRET_KEY_BASE=$(openssl rand -hex 64)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/chatwoot/.env
umask 022
ls -l /srv/chatwoot/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before writing it. Tell the user where the file is and that no human logs in with
any of these values. The Rails key is the one that matters for restores, because sessions signed
with a different key are rejected, which is why step 8 archives `.env`.

## 4. compose.yml

```bash
cat > /srv/chatwoot/compose.yml <<'EOF'
# Chatwoot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://developers.chatwoot.com/self-hosted/deployment/docker
#   variable reference . https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   requirements ....... https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Four services: the Rails web process, the Sidekiq worker every background job
# runs on, PostgreSQL and Redis. The database image is pgvector's, because
# Chatwoot's schema turns on the `vector` extension and a plain postgres refuses
# the schema load. The -ce tag is the community edition, built with the
# enterprise/ directory deleted, which is the tree the MIT licence covers.
# Digests read on 2026-08-06; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Rails and Sidekiq share an image and an environment; compose ignores x- keys.
x-chatwoot: &chatwoot
  image: chatwoot/chatwoot:v4.16.2-ce@sha256:7ee85a208147a86188ffc0e7fafafd2e1c0403b4ad6aea9e31f566662cce1d2f
  restart: unless-stopped
  env_file: /srv/chatwoot/.env
  environment:
    RAILS_ENV: production
    NODE_ENV: production
    INSTALLATION_ENV: docker
    POSTGRES_HOST: postgres
    POSTGRES_USERNAME: chatwoot
    POSTGRES_DATABASE: chatwoot_production
    REDIS_URL: redis://redis:6379
    # Signup stays shut: one account, made once through the onboarding screen.
    ENABLE_ACCOUNT_SIGNUP: "false"
    ACTIVE_STORAGE_SERVICE: local
  volumes:
    - /srv/chatwoot/storage:/app/storage
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: chatwoot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot_production
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/chatwoot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: chatwoot-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Doubled dollar: compose leaves it, the container's own shell expands it.
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"]
    volumes:
      - /srv/chatwoot/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $$REDIS_PASSWORD ping | grep -q PONG"]
      interval: 10s
      retries: 12

  rails:
    <<: *chatwoot
    container_name: chatwoot-rails
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8102.
      - "127.0.0.1:8102:3000"

  sidekiq:
    <<: *chatwoot
    container_name: chatwoot-sidekiq
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
EOF
cd /srv/chatwoot && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Sidekiq is not scenery: every outgoing message, webhook and
notification is a background job, so a Chatwoot with no worker is a dashboard whose replies
never leave.

## 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 other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-chatwoot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Chatwoot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developers.chatwoot.com/self-hosted/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also FRONTEND_URL in .env, so changing it later means editing .env too.

<DOMAIN> {
	# The dashboard holds customer conversations, so nothing here should be
	# framed, sniffed or leaked in 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

	# 8102 is the loopback port compose publishes on this host, not a container
	# port and not open in the firewall. Caddy upgrades the /cable websocket on
	# this same route and sets X-Forwarded-Proto, which is what lets Rails
	# accept that websocket as same-origin rather than rejecting it.
	reverse_proxy 127.0.0.1:8102
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-chatwoot, reload, and report what it objected to. Caddy requests the
certificate on first request and renews it itself, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box nothing changes:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8102 stays closed because compose binds it to 127.0.0.1, and 5432 and 6379 stay closed
because compose publishes no host port for them at all, unlike the upstream example file. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for
8102, 5432 or 6379.

## 7. Start and verify

Prepare the database once, before anything serves traffic. Upstream documents
`rails db:chatwoot_prepare` as the task that loads the schema on an empty database and migrates
an existing one; it also seeds the flag that unlocks the one-time onboarding screen.

```bash
cd /srv/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/api
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/accounts
curl -sS https://<DOMAIN>/installation/onboarding | grep -o 'Howdy, Welcome to Chatwoot'
```

Assert all five, printing what you received for each. The loop ends on `200`. The health
response is exactly `{"status":"woot"}`. The `/api` response contains `"queue_services":"ok"` and
`"data_services":"ok"`, Chatwoot reporting that it reached Redis and PostgreSQL itself rather
than you inferring it from container states. The unauthenticated POST prints `404`, because
signup is off, and that is the security assert here. The last command prints
`Howdy, Welcome to Chatwoot`. If any of the five misses, stop, run
`docker compose logs --tail 40 rails`, and name the likely cause:
`"data_services":"failing"` points at step 3 and a `.env` missing its password lines, and a `502`
where `200` was expected usually means Rails is still booting. A running container is not
success.

The first screen at https://<DOMAIN> shows the heading `Howdy, Welcome to Chatwoot`, a waving
emoji after it, above a form asking for a name, a company, a work email and a password.

STOP: tell the user to open https://<DOMAIN> and create their administrator account on that
screen, and wait. Do not continue until they confirm. That form runs once. Tell them to put the
password in their password manager as they type it: this install configures no mail, so there is
no reset email to fall back on.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/installation/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert: the first prints `302`, the onboarding screen now refusing, and the second prints `200`.
Both must pass before you report success.

## 8. First backup and restore

Two artifacts. The database holds every conversation, contact and agent; the config archive
holds the files and attachments that rebuild the service around them.

```bash
cd /srv/chatwoot
docker compose exec -T postgres pg_dump -U chatwoot -d chatwoot_production | gzip > /srv/chatwoot/backups/chatwoot-db-$(date +%F).sql.gz
sudo tar -czf /srv/chatwoot/backups/chatwoot-config-$(date +%F).tar.gz -C /srv/chatwoot compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/chatwoot/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 is in neither archive on purpose: it
holds the job queue and the 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/chatwoot
scp vps:/srv/chatwoot/backups/* ~/backups/chatwoot/
```

To restore: `docker compose down`, `sudo rm -rf /srv/chatwoot/postgres`, recreate it as in step
2, untar the config archive into /srv/chatwoot so `.env` is back before anything starts,
`docker compose up -d postgres`, wait for it to report healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U chatwoot -d chatwoot_production`, then
`docker compose up -d`. Tell the user the `.env` in that archive is not paperwork: a database
restored without it comes back with every session cookie failing to verify.

## 9. Updating later

New versions are listed at https://github.com/chatwoot/chatwoot/releases. Keep the `-ce` suffix.
Take both backup artifacts first, then edit the application image line in
/srv/chatwoot/compose.yml to the new tag and its digest:

```bash
cd /srv/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
docker compose logs --tail 30 rails
```

The prepare task is not optional here: upstream documents re-running it so the new image
migrates the database it inherited. Then re-run step 7's health checks.

## 10. What will probably go wrong

The wait. On a 4 GB box the prepare task in step 7 spent several minutes loading a schema with no
output at all, and then `docker compose up -d` returned instantly while Caddy answered `502` for
another two minutes because Rails was still eager-loading. I restarted the whole stack during
that window, convinced it had hung, and all that did was start the two minutes again. The loop
waits ten minutes on purpose. Let it run, and watch `docker compose logs -f rails` if you need
something to look at rather than something to press.

## 11. Out of scope

- Do not configure SMTP. Live chat works without it, and this install trades agent-invite and
  password-reset email for not fighting port 25 on a fresh VPS.
- Do not add a Facebook, Instagram, WhatsApp or email channel. Each is an app registration at
  somebody else's console, and none is needed for the widget.
- Do not set `ENABLE_ACCOUNT_SIGNUP` to true. Step 7 asserts that endpoint answers 404, and an
  open signup on a support desk is an open door to the conversation history.
- Do not switch `ACTIVE_STORAGE_SERVICE` to S3. Attachments belong on the disk step 8 archives,
  not in a bucket the backup cannot see.
````

## 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 Chatwoot 4.16.2, community edition, 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. `<DOMAIN>` becomes `FRONTEND_URL`, the address baked into the chat
widget snippet you paste on your website and into every link Chatwoot sends. Changing it later
means editing a file and recreating containers, so pick the hostname 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 `4096` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line. Upstream states 4 GB as the minimum for a Chatwoot that handles up
to 10,000 conversations a day, and Rails plus Sidekiq is where that goes.

If you do not: 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 hostname that
does not resolve and failed attempts count against a rate limit you cannot see. Under 4096 MB of
RAM, stop and resize the box rather than continuing: the failure mode is the OOM killer arriving
in the middle of the database migration in step 7, which leaves a half-loaded schema.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/chatwoot /srv/chatwoot/backups /srv/chatwoot/storage /srv/chatwoot/redis
sudo install -d -m 700 /srv/chatwoot/postgres
ls -la /srv/chatwoot
```

You should see: `backups`, `storage` and `redis` owned by you, and `postgres` at mode `drwx------`
owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `storage` is where customer attachments land, which is why it is yours and
in the backup.

## 3. Secrets

Three secrets: the Rails key that signs cookies and sessions, the PostgreSQL password and the
Redis password. All three are generated here, on the server, and all three go straight into a
file only you can read. Replace `<DOMAIN>` on the first line before you paste.

```bash
umask 077
cat > /srv/chatwoot/.env <<EOF
FRONTEND_URL=https://<DOMAIN>
SECRET_KEY_BASE=$(openssl rand -hex 64)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/chatwoot/.env
umask 022
ls -l /srv/chatwoot/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Hex rather than base64
because upstream asks for an alphanumeric value on the first one and the other two ride inside
connection strings.

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/chatwoot/.env` and carry on.
If the file already existed from an earlier attempt, this block has now overwritten all three,
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 Rails log rather than as anything about passwords.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. No human ever needs to read them, which makes this the easy rule to keep: your
own account password is the one you choose in step 7, in a browser.

## 4. compose.yml

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

```bash
cat > /srv/chatwoot/compose.yml <<'EOF'
# Chatwoot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://developers.chatwoot.com/self-hosted/deployment/docker
#   variable reference . https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   requirements ....... https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Four services: the Rails web process, the Sidekiq worker every background job
# runs on, PostgreSQL and Redis. The database image is pgvector's, because
# Chatwoot's schema turns on the `vector` extension and a plain postgres refuses
# the schema load. The -ce tag is the community edition, built with the
# enterprise/ directory deleted, which is the tree the MIT licence covers.
# Digests read on 2026-08-06; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Rails and Sidekiq share an image and an environment; compose ignores x- keys.
x-chatwoot: &chatwoot
  image: chatwoot/chatwoot:v4.16.2-ce@sha256:7ee85a208147a86188ffc0e7fafafd2e1c0403b4ad6aea9e31f566662cce1d2f
  restart: unless-stopped
  env_file: /srv/chatwoot/.env
  environment:
    RAILS_ENV: production
    NODE_ENV: production
    INSTALLATION_ENV: docker
    POSTGRES_HOST: postgres
    POSTGRES_USERNAME: chatwoot
    POSTGRES_DATABASE: chatwoot_production
    REDIS_URL: redis://redis:6379
    # Signup stays shut: one account, made once through the onboarding screen.
    ENABLE_ACCOUNT_SIGNUP: "false"
    ACTIVE_STORAGE_SERVICE: local
  volumes:
    - /srv/chatwoot/storage:/app/storage
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: chatwoot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot_production
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/chatwoot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: chatwoot-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Doubled dollar: compose leaves it, the container's own shell expands it.
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"]
    volumes:
      - /srv/chatwoot/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $$REDIS_PASSWORD ping | grep -q PONG"]
      interval: 10s
      retries: 12

  rails:
    <<: *chatwoot
    container_name: chatwoot-rails
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8102.
      - "127.0.0.1:8102:3000"

  sidekiq:
    <<: *chatwoot
    container_name: chatwoot-sidekiq
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
EOF
cd /srv/chatwoot && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/chatwoot/.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,
so run `rm /srv/chatwoot/compose.yml` and paste again in one go. The `sidekiq` service is not
optional scenery: every outgoing message, webhook and notification is a background job, and a
Chatwoot with no worker is a dashboard whose replies never leave.

## 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-chatwoot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Chatwoot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developers.chatwoot.com/self-hosted/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also FRONTEND_URL in .env, so changing it later means editing .env too.

<DOMAIN> {
	# The dashboard holds customer conversations, so nothing here should be
	# framed, sniffed or leaked in 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

	# 8102 is the loopback port compose publishes on this host, not a container
	# port and not open in the firewall. Caddy upgrades the /cable websocket on
	# this same route and sets X-Forwarded-Proto, which is what lets Rails
	# accept that websocket as same-origin rather than rejecting it.
	reverse_proxy 127.0.0.1:8102
}
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-chatwoot /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the
other. Caddy requests the certificate on the first request and renews it itself, so there is
nothing here to schedule.

## 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 `8102`, `5432` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8102`. 8102 is bound
to 127.0.0.1 by the compose file, and 5432 and 6379 are never published at all, unlike upstream's
example compose file which publishes both on the host. 80/tcp redirects to HTTPS and answers the
ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`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 further.

## 7. Start and verify

The database is prepared once, before anything serves traffic. Upstream documents
`rails db:chatwoot_prepare` as the task that loads the schema on an empty database and migrates
an existing one; it also seeds the flag that unlocks the one-time onboarding screen. The prepare
run can take several minutes and prints very little while it works.

```bash
cd /srv/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/api
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/accounts
curl -sS https://<DOMAIN>/installation/onboarding | grep -o 'Howdy, Welcome to Chatwoot'
```

You should see, in order: the loop reaching `200`, then exactly `{"status":"woot"}`, then a JSON
object containing `"queue_services":"ok"` and `"data_services":"ok"`, then `404`, then the line
`Howdy, Welcome to Chatwoot`.

If you do not: the `404` is the one worth understanding. It means account signup is off, which is
what this install wants, and it is the security check in this block. A `200` there would mean
anyone on the internet can create an account on your support desk. `"data_services":"failing"`
points back at step 3, where a `.env` missing its password lines leaves PostgreSQL unreachable;
`"queue_services":"failing"` is the same story for Redis. A `502` from Caddy while the loop is
still running is normal for the first two minutes; if the loop finishes forty rounds without a
`200`, run `docker compose logs --tail 40 rails` and `docker compose logs --tail 20 sidekiq`. A
running container is not success.

The first screen at https://<DOMAIN> shows the heading `Howdy, Welcome to Chatwoot`, a waving
emoji after it, above a form asking for a name, a company, a work email and a password.

Open https://<DOMAIN> in a browser now and fill that form in. It runs once, and it makes the only
administrator this install has. Put the password in your password manager as you type it, because
there is no SMTP configured here and therefore no password-reset email to fall back on.

Then prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/installation/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: `302` from the first, then `200` from the second.

If you do not: a `200` from the first means the onboarding form is still open and the account was
not created, so go back to the browser and finish it. That form is the one moment this install
would accept an administrator from anyone who could reach the URL, and closing it is the point of
this check.

## 8. First backup and restore

Two artifacts. The database holds every conversation, contact and agent; the config archive holds
the files and attachments that rebuild the service around them.

```bash
cd /srv/chatwoot
docker compose exec -T postgres pg_dump -U chatwoot -d chatwoot_production | gzip > /srv/chatwoot/backups/chatwoot-db-$(date +%F).sql.gz
sudo tar -czf /srv/chatwoot/backups/chatwoot-config-$(date +%F).tar.gz -C /srv/chatwoot compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/chatwoot/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline, because
`pg_dump` snapshots a running database consistently. Redis is in neither archive on purpose: it
holds the job queue and the caches, not durable data.

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/chatwoot
scp vps:/srv/chatwoot/backups/* ~/backups/chatwoot/
```

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

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 install:

```bash
cd /srv/chatwoot
docker compose down
sudo rm -rf /srv/chatwoot/postgres
sudo install -d -m 700 /srv/chatwoot/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/chatwoot/backups/chatwoot-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U chatwoot -d chatwoot_production
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 account still works when you sign in.

If you do not: `role "chatwoot" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the config archive
is for before you skip it: it carries `.env`, and the Rails key in that file is what verifies
every session cookie. Restore a database without it and everyone is signed out into an install
that no longer recognises its own tokens.

## 9. Updating later

New versions are listed at https://github.com/chatwoot/chatwoot/releases. Keep the `-ce` suffix on
the tag. Take both backup artifacts first, then edit the application image line in
/srv/chatwoot/compose.yml to the new tag and its digest.

```bash
cd /srv/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
docker compose logs --tail 30 rails
```

You should see: migration output from the prepare 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. The prepare task is not
optional on an update, because it is what migrates the database the new image inherited. Then
re-run the health checks from step 7 before you call the update done.

## 10. What will probably go wrong

The wait. On a 4 GB box the prepare task in step 7 spent several minutes loading a schema with no
output at all, and then `docker compose up -d` returned instantly while Caddy answered `502` for
another two minutes because Rails was still eager-loading. I restarted the whole stack during
that window, convinced it had hung, and all that did was start the two minutes again. The loop
waits ten minutes on purpose. Let it run, and watch `docker compose logs -f rails` if you need
something to look at rather than something to press.

## 11. Out of scope

- Do not configure SMTP. Live chat works without it, and this install trades agent-invite and
  password-reset email for not fighting port 25 on a fresh VPS.
- Do not add a Facebook, Instagram, WhatsApp or email channel. Each is an app registration at
  somebody else's console, and none is needed for the widget.
- Do not set `ENABLE_ACCOUNT_SIGNUP` to true. Step 7 asserts that endpoint answers 404, and an
  open signup on a support desk is an open door to the conversation history.
- Do not switch `ACTIVE_STORAGE_SERVICE` to S3. Attachments belong on the disk step 8 archives,
  not in a bucket the backup cannot see.
````

## 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 Chatwoot 4.16.2, community edition, with the PostgreSQL and Redis it needs, under
~/selfhost/chatwoot, answering at http://localhost:8102.

## 1. Preflight

Say this before step 2 runs; it decides whether they want this install at all. The widget
Chatwoot generates loads its script from http://localhost:8102, so no visitor on another machine
can open it. What is left is the dashboard, the help center and the API: a place to learn the
tool, not a chat customers can reach.

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. Chatwoot needs 4096 MB of RAM available and 20 GB free on
the home disk, upstream's minimum; all three images publish amd64 and arm64. On macOS and Windows
raise Docker Desktop's own memory allocation to at least 4 GB first. If available RAM is under
4096 MB or free disk is under 20 GB, 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/chatwoot/storage ~/selfhost/chatwoot/redis ~/selfhost/chatwoot/backups
ls -la ~/selfhost/chatwoot
```

Assert: `ls -la` shows `storage`, `redis` and `backups`, owned by the user. Nothing here needs a
chown: the containers run as root inside themselves and write into folders the user owns. The
database gets a Docker-managed volume instead, because that image picks its own uid.

## 4. Secrets

Three secrets: the Rails key that signs cookies and sessions, the PostgreSQL password and the
Redis password. Generate all three here, print none, and keep them out of your summary and logs.

```bash
umask 077
cat > ~/selfhost/chatwoot/.env <<EOF
FRONTEND_URL=http://localhost:8102
SECRET_KEY_BASE=$(openssl rand -hex 64)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/chatwoot/.env
umask 022
ls -l ~/selfhost/chatwoot/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, and no human logs in with these values. On
Windows the mode bits are advisory: NTFS ignores them, and the user's account is the boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/chatwoot/compose.yml <<'EOF'
# Chatwoot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://developers.chatwoot.com/self-hosted/deployment/docker
#   variable reference . https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   requirements ....... https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Four services, every path relative to ~/selfhost/chatwoot/, so one file works
# on macOS, Linux and Windows. The database is a named volume because the
# PostgreSQL image chowns its data directory to its own uid, which a
# home-directory bind mount cannot allow on Windows; that image is pgvector's
# because Chatwoot's schema turns on `vector`; -ce is the community edition,
# built with enterprise/ deleted. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Rails and Sidekiq share an image and an environment; compose ignores x- keys.
x-chatwoot: &chatwoot
  image: chatwoot/chatwoot:v4.16.2-ce@sha256:7ee85a208147a86188ffc0e7fafafd2e1c0403b4ad6aea9e31f566662cce1d2f
  restart: unless-stopped
  env_file: ./.env
  environment:
    RAILS_ENV: production
    NODE_ENV: production
    INSTALLATION_ENV: docker
    POSTGRES_HOST: postgres
    POSTGRES_USERNAME: chatwoot
    POSTGRES_DATABASE: chatwoot_production
    REDIS_URL: redis://redis:6379
    # Signup stays shut: one account, made once through the onboarding screen.
    ENABLE_ACCOUNT_SIGNUP: "false"
    ACTIVE_STORAGE_SERVICE: local
  volumes:
    - ./storage:/app/storage
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: chatwoot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot_production
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - chatwoot-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: chatwoot-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Doubled dollar: compose leaves it, the container's own shell expands it.
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"]
    volumes:
      - ./redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $$REDIS_PASSWORD ping | grep -q PONG"]
      interval: 10s
      retries: 12

  rails:
    <<: *chatwoot
    container_name: chatwoot-rails
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    ports:
      # Loopback only: no other device on the wifi can reach 8102.
      - "127.0.0.1:8102:3000"

  sidekiq:
    <<: *chatwoot
    container_name: chatwoot-sidekiq
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]

volumes:
  chatwoot-pgdata:
EOF
cd ~/selfhost/chatwoot && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 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 and nothing to wait for.
- No TLS. Nothing here 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.

8102 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet.
For an inbox of other people's messages that is the trade. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/chatwoot/compose.yml
```

Assert: one line, `- "127.0.0.1:8102:3000"`. PostgreSQL and Redis publish no host port.

## 7. Start and verify

Prepare the database first. Upstream documents `rails db:chatwoot_prepare` as the task that loads
the schema and seeds the onboarding flag, then migrates later.

```bash
cd ~/selfhost/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8102/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8102/health
curl -sS http://localhost:8102/api
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8102/api/v1/accounts
curl -sS http://localhost:8102/installation/onboarding | grep -o 'Howdy, Welcome to Chatwoot'
```

Assert all five, printing what you received for each. The loop ends on `200`; the health response
is exactly `{"status":"woot"}`; `/api` contains `"queue_services":"ok"` and
`"data_services":"ok"`, Chatwoot reporting that it reached Redis and PostgreSQL itself; the
unauthenticated POST prints `404`, because signup is off, the security assert here; the last
command prints the onboarding heading. If any misses, stop, run
`docker compose logs --tail 40 rails` and name the cause: `"data_services":"failing"` is step 4
and a `.env` missing its password lines, `port is already allocated` is something else on 8102,
a log still eager-loading wants time. A running container is not success.

The first screen at http://localhost:8102 shows the heading `Howdy, Welcome to Chatwoot`, a
waving emoji after it, above a form asking for a name, a company, a work email and a password.

STOP: tell the user to open http://localhost:8102 and create their administrator account there,
and wait. Do not continue until they confirm. That form runs once, and this install has no mail,
so tell them to put the password in their password manager as they type it.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8102/installation/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8102/
```

Assert: the first prints `302`, the onboarding screen refusing, and the second prints `200`.

## 8. First backup and restore

Two artifacts: a dump of every conversation and contact, and a config archive of the rest.

```bash
cd ~/selfhost/chatwoot
docker compose exec -T postgres pg_dump -U chatwoot -d chatwoot_production | gzip > ~/selfhost/chatwoot/backups/chatwoot-db-$(date +%F).sql.gz
tar -C ~/selfhost/chatwoot -czf ~/selfhost/chatwoot/backups/chatwoot-config-$(date +%F).tar.gz compose.yml .env storage
ls -lh ~/selfhost/chatwoot/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database. Redis holds queues and caches, so it is 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 plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/chatwoot`, untar the config archive there first so
compose.yml and .env are back before any container starts: PostgreSQL takes its password from
.env when it initialises an empty volume, and the Rails key there is what makes restored sessions
verify. Then `docker compose down -v`, the one place `-v` belongs, `docker compose up -d postgres`,
wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U chatwoot -d chatwoot_production`, then
`docker compose up -d`. That is the whole disaster plan.

## 9. Updating later

New versions are at https://github.com/chatwoot/chatwoot/releases; keep the `-ce` suffix. Back up
first, then edit the image line in ~/selfhost/chatwoot/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/chatwoot
docker compose pull
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
docker compose logs --tail 30 rails
```

That prepare run migrates the database the new image inherited. Then re-run step 7's checks.

## 10. What will probably go wrong

I closed the laptop lid with a conversation open, came back an hour later, and the dashboard sat
there showing nothing new and no error. Nothing was broken: the machine had slept, Docker Desktop
with it, and Sidekiq had not been running to deliver anything. The same happens after a reboot,
because `restart: unless-stopped` acts only once the Docker daemon is up. Turn on Docker
Desktop's start-at-login, and run `cd ~/selfhost/chatwoot && docker compose up -d` after one.

## 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, and do not add a Facebook, Instagram, WhatsApp or email channel: each
  needs mail or a webhook URL the provider can reach, and nothing here has either.
- Do not set `ENABLE_ACCOUNT_SIGNUP` to true. Step 7 asserts that endpoint answers 404.
````

## docker-compose.yml

```yaml
# Chatwoot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://developers.chatwoot.com/self-hosted/deployment/docker
#   variable reference . https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   requirements ....... https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Four services: the Rails web process, the Sidekiq worker every background job
# runs on, PostgreSQL and Redis. The database image is pgvector's, because
# Chatwoot's schema turns on the `vector` extension and a plain postgres refuses
# the schema load. The -ce tag is the community edition, built with the
# enterprise/ directory deleted, which is the tree the MIT licence covers.
# Digests read on 2026-08-06; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Rails and Sidekiq share an image and an environment; compose ignores x- keys.
x-chatwoot: &chatwoot
  image: chatwoot/chatwoot:v4.16.2-ce@sha256:7ee85a208147a86188ffc0e7fafafd2e1c0403b4ad6aea9e31f566662cce1d2f
  restart: unless-stopped
  env_file: /srv/chatwoot/.env
  environment:
    RAILS_ENV: production
    NODE_ENV: production
    INSTALLATION_ENV: docker
    POSTGRES_HOST: postgres
    POSTGRES_USERNAME: chatwoot
    POSTGRES_DATABASE: chatwoot_production
    REDIS_URL: redis://redis:6379
    # Signup stays shut: one account, made once through the onboarding screen.
    ENABLE_ACCOUNT_SIGNUP: "false"
    ACTIVE_STORAGE_SERVICE: local
  volumes:
    - /srv/chatwoot/storage:/app/storage
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: chatwoot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot_production
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/chatwoot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: chatwoot-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Doubled dollar: compose leaves it, the container's own shell expands it.
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"]
    volumes:
      - /srv/chatwoot/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $$REDIS_PASSWORD ping | grep -q PONG"]
      interval: 10s
      retries: 12

  rails:
    <<: *chatwoot
    container_name: chatwoot-rails
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8102.
      - "127.0.0.1:8102:3000"

  sidekiq:
    <<: *chatwoot
    container_name: chatwoot-sidekiq
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
```

## compose.local.yml

```yaml
# Chatwoot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://developers.chatwoot.com/self-hosted/deployment/docker
#   variable reference . https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   requirements ....... https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Four services, every path relative to ~/selfhost/chatwoot/, so one file works
# on macOS, Linux and Windows. The database is a named volume because the
# PostgreSQL image chowns its data directory to its own uid, which a
# home-directory bind mount cannot allow on Windows; that image is pgvector's
# because Chatwoot's schema turns on `vector`; -ce is the community edition,
# built with enterprise/ deleted. Digests read 2026-08-06, amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# Rails and Sidekiq share an image and an environment; compose ignores x- keys.
x-chatwoot: &chatwoot
  image: chatwoot/chatwoot:v4.16.2-ce@sha256:7ee85a208147a86188ffc0e7fafafd2e1c0403b4ad6aea9e31f566662cce1d2f
  restart: unless-stopped
  env_file: ./.env
  environment:
    RAILS_ENV: production
    NODE_ENV: production
    INSTALLATION_ENV: docker
    POSTGRES_HOST: postgres
    POSTGRES_USERNAME: chatwoot
    POSTGRES_DATABASE: chatwoot_production
    REDIS_URL: redis://redis:6379
    # Signup stays shut: one account, made once through the onboarding screen.
    ENABLE_ACCOUNT_SIGNUP: "false"
    ACTIVE_STORAGE_SERVICE: local
  volumes:
    - ./storage:/app/storage
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

services:
  postgres:
    image: pgvector/pgvector:0.8.6-pg16@sha256:a36250871de0833b8757561c72f2477ef1ddd1101afa4e617fb552e0de514c6b
    container_name: chatwoot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot_production
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - chatwoot-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatwoot -d chatwoot_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: chatwoot-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Doubled dollar: compose leaves it, the container's own shell expands it.
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass $$REDIS_PASSWORD"]
    volumes:
      - ./redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $$REDIS_PASSWORD ping | grep -q PONG"]
      interval: 10s
      retries: 12

  rails:
    <<: *chatwoot
    container_name: chatwoot-rails
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    ports:
      # Loopback only: no other device on the wifi can reach 8102.
      - "127.0.0.1:8102:3000"

  sidekiq:
    <<: *chatwoot
    container_name: chatwoot-sidekiq
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]

volumes:
  chatwoot-pgdata:
```

## Caddyfile

```text
# Chatwoot · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developers.chatwoot.com/self-hosted/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also FRONTEND_URL in .env, so changing it later means editing .env too.

<DOMAIN> {
	# The dashboard holds customer conversations, so nothing here should be
	# framed, sniffed or leaked in 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

	# 8102 is the loopback port compose publishes on this host, not a container
	# port and not open in the firewall. Caddy upgrades the /cable websocket on
	# this same route and sets X-Forwarded-Proto, which is what lets Rails
	# accept that websocket as same-origin rather than rejecting it.
	reverse_proxy 127.0.0.1:8102
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Chatwoot · 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=support.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://developers.chatwoot.com/self-hosted/deployment/docker
#   https://developers.chatwoot.com/self-hosted/configuration/environment-variables
#   https://developers.chatwoot.com/self-hosted/deployment/requirements
#
# Three secrets are generated here, on this machine: the Rails key that signs
# cookies and sessions, the PostgreSQL password and the Redis password. All
# three go into /srv/chatwoot/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST is also FRONTEND_URL, the address baked into the chat widget
# snippet you paste on your site and into every link Chatwoot sends.
#
# This script stops one step short of a usable install on purpose: only a human
# in a browser can fill in the one-time onboarding form that creates the
# administrator account. The closing summary says where.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/chatwoot}"
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. support.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 4096 ] || die "only ${avail_mb} MB of RAM available; Rails plus Sidekiq plus PostgreSQL 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 ----------------------------------------------------
#
# 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/storage" "$APP_DIR/redis"
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 three secrets, on the server ----------------------------
#
# Hex for all three: upstream asks for an alphanumeric SECRET_KEY_BASE, and the
# other two ride inside connection strings and a container command line. No
# human logs in with any of them; the administrator password is chosen in a
# browser at the end.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		FRONTEND_URL=https://${DOMAIN_HOST}
		SECRET_KEY_BASE=$(openssl rand -hex 64)
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		REDIS_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-chatwoot"
	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 8102, 5432 and 6379 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; 8102, 5432 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. Prepare the database, then start it ----------------------------------
#
# db:chatwoot_prepare loads the schema on an empty database and migrates an
# existing one. It also seeds the flag that unlocks the onboarding screen.

docker compose pull
echo "==> loading the schema; this prints little and takes minutes"
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status":"woot"' \
	|| die "/health answered 200 without status woot. Check: docker compose logs --tail 40 rails"

# Chatwoot reports on its own dependencies here, which beats guessing from
# container states: both must read ok.
api="$(curl -sS "https://${DOMAIN_HOST}/api" || true)"
printf '%s' "$api" | grep -q '"queue_services":"ok"' || die "Chatwoot cannot reach Redis. Check: docker compose logs --tail 20 redis"
printf '%s' "$api" | grep -q '"data_services":"ok"' || die "Chatwoot cannot reach PostgreSQL. Check: docker compose logs --tail 20 postgres"

# Account signup must be off. Upstream's default is false, and an open endpoint
# here would let anyone on the internet create an account on this support desk.
signup="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/v1/accounts" || true)"
[ "$signup" = "404" ] || die "POST /api/v1/accounts returned ${signup}, not 404. Stop and investigate."

curl -sS "https://${DOMAIN_HOST}/installation/onboarding" | grep -q 'Howdy, Welcome to Chatwoot' \
	|| die "the onboarding screen did not render. Check: docker compose logs --tail 40 rails"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U chatwoot -d chatwoot_production | gzip > "$APP_DIR/backups/chatwoot-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/chatwoot-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/chatwoot-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Chatwoot is answering at https://${DOMAIN_HOST}/health

	  1. One step is left and only you can do it. Open
	       https://${DOMAIN_HOST}/installation/onboarding
	     and fill in the form headed "Howdy, Welcome to Chatwoot". It creates
	     the single administrator account and then refuses to run again. Put
	     that password in your password manager as you type it: this install
	     configures no mail, so there is no reset email.
	  2. Account signup is off, and this script checked it: an unauthenticated
	     POST to /api/v1/accounts answers 404.
	  3. Three secrets live in $APP_DIR/.env, mode 600, none of them printed
	     here. No human signs in with any of them. The Rails key in that file
	     is what verifies session cookies, so a database restored without it
	     signs everybody out.
	  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.
	     They are on the same disk as the data, which is not a backup. Copy them
	     somewhere else tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/intercom/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
