# Can I self-host Chatbase?

**YES, BUT** — it's called Typebot. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $40/mo you stop paying ($480/yr on the Hobby plan).

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

## 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 Typebot 3.17.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.

Typebot is two applications, so this install answers on two names: the builder, where bots are
designed and the user signs in, on `<DOMAIN>`, and the viewer, what a visitor loads when they open
a published bot, on `bot.<DOMAIN>`. Both are Next.js servers owning the root path, so they cannot
share a hostname. Tell the user now: both names go into links they hand out, and both need an A
record on this server.

Settle one thing more, because step 3 stops dead without it: Typebot registers no sign-in method
at all until a mail relay or an outside identity provider is configured, and this install uses
mail. Tell the user to have a host, port, username, password and a from-address from a
transactional mail provider in front of them.

Typebot and its PostgreSQL need 2048 MB of RAM available and 15 GB free on /srv: the two
application images are over a gigabyte each compressed. All three publish amd64 and arm64.

```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>
dig +short bot.<DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 15 GB, print both numbers and stop. If
either `dig +short` prints nothing, print which one and stop: Caddy cannot issue a certificate for
a name that will not resolve.

## 2. Layout

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

Assert: `ls -la` shows `backups` 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 leave that alone.
Nothing else of Typebot's lives on disk: bots, results and credentials are rows.

## 3. Secrets

Two secrets: the PostgreSQL password and `ENCRYPTION_SECRET`. Generate both on the server. Do not
print either, do not repeat them in your summary, and do not put them in a log line. Upstream
documents `openssl rand -base64 24` for `ENCRYPTION_SECRET`, and its schema rejects anything that
is not exactly 32 characters, which is what 24 random bytes of base64 come to.

```bash
umask 077
cat > /srv/typebot/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>
NEXT_PUBLIC_VIEWER_URL=https://bot.<DOMAIN>
NODE_OPTIONS=--no-node-snapshot
DISABLE_SIGNUP=true
DEFAULT_WORKSPACE_PLAN=UNLIMITED
ENCRYPTION_SECRET=$(openssl rand -base64 24)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
ADMIN_EMAIL=CHANGE_ME
NEXT_PUBLIC_SMTP_FROM=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/typebot/.env
umask 022
ls -l /srv/typebot/.env
```

Assert: the file exists with mode `-rw-------`, with `<DOMAIN>` on the first two lines replaced by
the real hostname. `DISABLE_SIGNUP` is true from the first boot and upstream's sign-in callback
lets exactly one address past it, whatever is in `ADMIN_EMAIL`, so there is no open-registration
window and nothing to close later. `DEFAULT_WORKSPACE_PLAN=UNLIMITED` overrides a `FREE` default
upstream's constants cap at 200 chats a month and one seat, and `NEXT_PUBLIC_SMTP_FROM` is what
registers the email sign-in provider at all.

STOP: tell the user to open `nano /srv/typebot/.env`, replace every `CHANGE_ME`, put their own
address in `ADMIN_EMAIL` because it is the only one that can create an account, correct
`SMTP_PORT` if their relay is not 587, set `SMTP_SECURE=true` if it is 465, and save. Do not
continue until they confirm, and never ask them to paste a value.

```bash
grep -c CHANGE_ME /srv/typebot/.env || true
```

Assert: that prints `0`. It counts lines, never values.

## 4. compose.yml

```bash
cat > /srv/typebot/compose.yml <<'EOF'
# Typebot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://docs.typebot.com/self-hosting/deploy/docker
#   configuration ... https://docs.typebot.com/self-hosting/configuration
#
# Three services: builder, viewer, and the PostgreSQL holding both. Builder and
# viewer are Next.js servers that each own the root path, so each needs its own
# hostname and its own loopback port, and only the builder migrates the
# database. Neither image ships curl, so the health checks use node. Digests
# read 2026-08-07, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/typebot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U typebot -d typebot"]
      interval: 10s
      retries: 12

  builder:
    image: baptistearno/typebot-builder:3.17.2@sha256:a67edf944eb64e885a3660d8bbd11102b9d468d31dbf4b7f6170e4cd2ceaa9d3
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/auth/providers').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      retries: 24
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8177.
      - "127.0.0.1:8177:3000"
    depends_on:
      postgres:
        condition: service_healthy

  viewer:
    image: baptistearno/typebot-viewer:3.17.2@sha256:70f1dd949f2246432650cfda082c01e45089fb129369ceee6632d57b9c5f2b7e
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    ports:
      # Loopback only: Caddy is the only thing that reaches 8977.
      - "127.0.0.1:8977:3000"
    depends_on:
      builder:
        condition: service_healthy
EOF
cd /srv/typebot && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No password sits in that file: compose reads
`${POSTGRES_PASSWORD}` from /srv/typebot/.env to build both connection strings.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced everywhere
it appears. 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-typebot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Typebot · the Caddy site blocks for this service.
#
# Authored by caniselfhostit from
# https://docs.typebot.com/self-hosting/deploy/docker and
# https://caddyserver.com/docs/automatic-https
#
# Two site blocks, because Typebot is two applications that cannot share a
# hostname. Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the
# hostname pointed at this box; bot.<DOMAIN> needs its own A record on the
# same address.

<DOMAIN> {
	# The builder. Sign-in codes land here and every bot design sits behind
	# that session, so nothing on this name should be framed by another site.
	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

	# 8177 is a loopback port, not a container port, and not in the firewall.
	reverse_proxy 127.0.0.1:8177
}

bot.<DOMAIN> {
	# The viewer, embedded in other people's pages on purpose, so no frame
	# restriction. An AI block streams, so this route flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	reverse_proxy 127.0.0.1:8977 {
		flush_interval -1
	}
}
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-typebot, reload, and report what it objected to. Caddy issues both
certificates on the first request to each name and renews them itself.

## 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 answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8177 and 8977 stay closed because compose binds both to loopback, 5432 because compose
never publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8177, 8977 or 5432.

## 7. Start and verify

The first pull is over two gigabytes, and the builder applies its Prisma migrations before it
listens, so the first boot takes minutes.

```bash
cd /srv/typebot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/auth/providers); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/auth/providers
curl -sS https://bot.<DOMAIN>/api/healthz
curl -sS -o /dev/null -w '%{http_code}\n' https://bot.<DOMAIN>/api/typebots
grep -c '^DISABLE_SIGNUP=true$' /srv/typebot/.env
```

Assert all five, and print what you received for each. The loop ends printing `200`. The providers
response is JSON containing `"nodemailer"`, the email sign-in method, which proves step 3's relay
settings were read. The viewer answers `{"status":"ok"}`. The unauthenticated call to
the viewer's API prints `401`, upstream's answer to a request with no bearer token, and that is
the security assert. The grep prints `1`. If any of the five misses, stop, run
`docker compose logs --tail 60 builder` and `docker compose logs --tail 20 postgres`, and name the
likely cause: a database that never reports healthy points at step 2; `Invalid environment
variables` points at step 3, where an `ENCRYPTION_SECRET` that is not exactly 32 characters stops
the process before it listens; a `502` means migrations are still going; `{}` from providers means
`NEXT_PUBLIC_SMTP_FROM` is empty. A running container is not success.

The first screen at https://<DOMAIN>/signin is headed `Sign In`, with `Don't have an account?`
under it and one box asking for an email address next to a `Submit` button.

STOP: tell the user to open https://<DOMAIN>/signin, enter the address they put in `ADMIN_EMAIL`,
and type in the six-digit code Typebot mails to it. Do not continue until they confirm they see an
empty bot list. That code arriving is the only proof the relay works; if nothing lands within two
minutes, read step 10 first. Any other address is refused with `Unauthorized`, which is what
`DISABLE_SIGNUP` does.

## 8. First backup and restore

Two artifacts. The database holds every bot, result and stored credential; the config archive
holds what rebuilds the service around it.

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

Assert: both files exist and are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database. A backup on the same disk is not a backup, so run this from the
user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/typebot/postgres`, recreate it as in step 2,
untar the config archive into /srv/typebot so .env is back before anything starts,
`docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U typebot -d typebot`, then `docker compose up -d`. The
archive matters as much as the dump: `ENCRYPTION_SECRET` in .env decrypts the provider keys in the
database, so a dump restored beside a freshly generated secret is unreadable.

## 9. Updating later

New versions are listed at https://github.com/baptisteArno/typebot.io/releases. Take both backups
first, then edit both `image:` lines in /srv/typebot/compose.yml to the new tag and digest,
keeping builder and viewer on the same version:

```bash
cd /srv/typebot
docker compose pull
docker compose up -d
docker compose logs --tail 40 builder
```

The builder migrates the database on the way up, so watch that log until it settles, then re-run
step 7's five checks. Leave the postgres tag alone unless a release note says so.

## 10. What will probably go wrong

The sign-in code will not arrive and nothing will look broken. I sat on the login-code screen with
three healthy containers, a `200` from the providers endpoint and an empty inbox, because the
relay had refused the message out of sight: Typebot only logs `Magic link email could not be sent`
when the send itself throws. Read `docker compose logs --tail 60 builder` first, since a rejected
from-address or a failed relay login shows up there. Mine was the from-address, on a domain the
relay had not verified. Check spam second, and only then suspect the install.

## 11. Out of scope

- Do not configure Google, GitHub, GitLab, Facebook, Azure AD or Keycloak sign-in. Each needs a
  client registered in somebody else's console; this install signs people in by email.
- Do not add S3 storage or a MinIO container. Media uploads inside bots want an object store on a
  third hostname, and an unset `S3_ACCESS_KEY` switches those blocks off.
- Do not add upstream's Redis container. It buys a per-IP rate limit on sign-ins.
- Do not install a mail server here. Use the relay from step 3; port 25 on a fresh VPS is a fight
  with no prize.
````

## 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 Typebot 3.17.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise.

Two things to settle before step 1, because both stop this install dead.

Typebot is two applications and needs two hostnames. The builder, where you design bots and sign
in, answers on `<DOMAIN>`. The viewer, which is what a visitor loads when they open a published
bot, answers on `bot.<DOMAIN>`. Both are Next.js servers that own the root path, so they cannot
share one name. Point A records for both at this server before you start, and replace `<DOMAIN>`
with your hostname everywhere it appears below.

Typebot signs people in by mailing a six-digit code, and it registers no sign-in method at all
until a mail relay or an outside identity provider is configured. This install uses mail. Have a
host, port, username, password and a from-address from a transactional mail provider in front of
you before step 3.

## 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>
dig +short bot.<DOMAIN>
```

You should see: at least `2048` MB available, at least `15` G free, `amd64` or `arm64`, and your
server's IP twice on the last two lines. The disk floor is high because the two application images
are over a gigabyte each compressed.

If you do not: an empty line from either `dig` means that A record does not exist yet. Add it,
wait a minute, run the command again. Caddy cannot get a certificate for a name that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for both names
while you install.

## 2. Layout

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

You should see: `backups` 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. Nothing else of Typebot's lives on disk: bots, results and credentials are rows in
that database.

## 3. Secrets

Two secrets are generated here, on the server, and both go straight into a file only you can read.
Upstream documents `openssl rand -base64 24` for `ENCRYPTION_SECRET`, and its schema rejects
anything that is not exactly 32 characters, which is what 24 random bytes of base64 come to. The
database password is hex so it needs no escaping inside a connection string.

```bash
umask 077
cat > /srv/typebot/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>
NEXT_PUBLIC_VIEWER_URL=https://bot.<DOMAIN>
NODE_OPTIONS=--no-node-snapshot
DISABLE_SIGNUP=true
DEFAULT_WORKSPACE_PLAN=UNLIMITED
ENCRYPTION_SECRET=$(openssl rand -base64 24)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
ADMIN_EMAIL=CHANGE_ME
NEXT_PUBLIC_SMTP_FROM=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/typebot/.env
umask 022
ls -l /srv/typebot/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first two lines with your real hostname before you paste.

Do not paste that file, either generated secret, or any command output containing them into this
chat window. The agent path never sees those values; a chat window will hand them to a third party
unless you keep them out of it.

Now open `nano /srv/typebot/.env` and replace every `CHANGE_ME`. `ADMIN_EMAIL` is your own address,
and it is the only address in the world that can create an account on this instance.
`NEXT_PUBLIC_SMTP_FROM` is the from-address your relay is allowed to send as. Correct `SMTP_PORT`
if your relay is not 587, and set `SMTP_SECURE=true` if it is 465. Then:

```bash
grep -c CHANGE_ME /srv/typebot/.env
```

You should see: `0`. That counts lines, never values.

If you do not: three of those settings are load-bearing and worth understanding before you move
on. `DISABLE_SIGNUP` is true from the first boot, and upstream's sign-in callback lets exactly one
address past it, whatever is in `ADMIN_EMAIL`, so there is no open-registration window on this
install and nothing to close later. `DEFAULT_WORKSPACE_PLAN=UNLIMITED` overrides a `FREE` default
that upstream's own constants cap at 200 chats a month and one seat. `NEXT_PUBLIC_SMTP_FROM` is
what registers the email sign-in provider at all: leave it empty and the builder serves a sign-in
page with no way to sign in. A mode of `-rw-r--r--` instead means `umask 077` did not take effect,
which happens if you pasted the lines separately in different shells; run
`chmod 600 /srv/typebot/.env` and carry on.

## 4. compose.yml

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

```bash
cat > /srv/typebot/compose.yml <<'EOF'
# Typebot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://docs.typebot.com/self-hosting/deploy/docker
#   configuration ... https://docs.typebot.com/self-hosting/configuration
#
# Three services: builder, viewer, and the PostgreSQL holding both. Builder and
# viewer are Next.js servers that each own the root path, so each needs its own
# hostname and its own loopback port, and only the builder migrates the
# database. Neither image ships curl, so the health checks use node. Digests
# read 2026-08-07, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/typebot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U typebot -d typebot"]
      interval: 10s
      retries: 12

  builder:
    image: baptistearno/typebot-builder:3.17.2@sha256:a67edf944eb64e885a3660d8bbd11102b9d468d31dbf4b7f6170e4cd2ceaa9d3
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/auth/providers').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      retries: 24
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8177.
      - "127.0.0.1:8177:3000"
    depends_on:
      postgres:
        condition: service_healthy

  viewer:
    image: baptistearno/typebot-viewer:3.17.2@sha256:70f1dd949f2246432650cfda082c01e45089fb129369ceee6632d57b9c5f2b7e
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    ports:
      # Loopback only: Caddy is the only thing that reaches 8977.
      - "127.0.0.1:8977:3000"
    depends_on:
      builder:
        condition: service_healthy
EOF
cd /srv/typebot && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/typebot/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal;
run `rm /srv/typebot/compose.yml` and paste again in one go. No password sits in that file:
compose reads `${POSTGRES_PASSWORD}` out of /srv/typebot/.env to build both connection strings.

## 5. Caddy and TLS

This appends two site blocks to the Caddy config Prompt Zero installed, one per application.
Replace `<DOMAIN>` in the block with your hostname everywhere it appears 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-typebot
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Typebot · the Caddy site blocks for this service.
#
# Authored by caniselfhostit from
# https://docs.typebot.com/self-hosting/deploy/docker and
# https://caddyserver.com/docs/automatic-https
#
# Two site blocks, because Typebot is two applications that cannot share a
# hostname. Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the
# hostname pointed at this box; bot.<DOMAIN> needs its own A record on the
# same address.

<DOMAIN> {
	# The builder. Sign-in codes land here and every bot design sits behind
	# that session, so nothing on this name should be framed by another site.
	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

	# 8177 is a loopback port, not a container port, and not in the firewall.
	reverse_proxy 127.0.0.1:8177
}

bot.<DOMAIN> {
	# The viewer, embedded in other people's pages on purpose, so no frame
	# restriction. An AI block streams, so this route flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	reverse_proxy 127.0.0.1:8977 {
		flush_interval -1
	}
}
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-typebot /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you replaced in one of the three places and not
the others. Caddy asks for both certificates on the first request to each name and renews them
itself, so there is nothing 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 `8177`, `8977` or `5432`.

If you do not: delete anything for those three with `sudo ufw delete allow 8177`. Both application
ports are bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the
database has no host port a rule could apply to. 80/tcp is there to redirect to HTTPS and answer
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.

## 7. Start and verify

The first pull is over two gigabytes, and the builder applies its own Prisma migrations before it
listens, so the first boot takes minutes rather than seconds.

```bash
cd /srv/typebot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/auth/providers); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/auth/providers
curl -sS https://bot.<DOMAIN>/api/healthz
curl -sS -o /dev/null -w '%{http_code}\n' https://bot.<DOMAIN>/api/typebots
grep -c '^DISABLE_SIGNUP=true$' /srv/typebot/.env
```

You should see, in order: the loop reaching `200`, a JSON object containing `"nodemailer"`, then
`{"status":"ok"}` from the viewer, then `401`, then `1`.

If you do not: the `401` is the one worth understanding. It means the viewer's API is up and
refusing a call with no bearer token, which is upstream's documented answer, so seeing it is good
news. A `404` in its place means Caddy is not reaching the viewer container: check
`docker compose ps`. If the providers response is `{}`, the email provider did not register, which
means `NEXT_PUBLIC_SMTP_FROM` is still empty in .env. If the loop never reaches `200`, run
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy is
step 2 done wrong, and `docker compose logs --tail 60 builder` second: an `Invalid environment
variables` line there points at step 3, where an `ENCRYPTION_SECRET` that is not exactly 32
characters stops the process before it listens, and a `502` while the loop is still running only
means migrations are still going.

The first screen at https://<DOMAIN>/signin is headed `Sign In`, with `Don't have an account?`
under it and one box asking for an email address next to a `Submit` button.

Now open https://<DOMAIN>/signin in a browser, enter the address you put in `ADMIN_EMAIL`, and
type in the six-digit code Typebot mails to it. You should land on an empty bot list. That code
arriving is the only proof your relay works; if nothing lands within two minutes, read step 10
before touching anything. Any other address is refused with `Unauthorized`, which is what
`DISABLE_SIGNUP` does, and it is the security assert on this install: registration left open on a
public hostname is an account for anyone who can receive mail.

## 8. First backup and restore

Two artifacts. The database holds every bot, result, workspace and stored credential. The config
archive holds what rebuilds the service around it.

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

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

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

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

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

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 `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty account:

```bash
cd /srv/typebot
docker compose down
sudo rm -rf /srv/typebot/postgres
sudo install -d -m 700 /srv/typebot/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/typebot/backups/typebot-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U typebot -d typebot
docker compose up -d
sleep 60
curl -sS https://bot.<DOMAIN>/api/healthz
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"status":"ok"}` again.

If you do not: `role "typebot" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand why the config archive
matters as much as the dump: `ENCRYPTION_SECRET` lives in .env, and it is what decrypts the
provider keys and integration credentials stored in the database, so a dump restored beside a
freshly generated secret comes back with credentials nobody can read.

## 9. Updating later

New versions are listed at https://github.com/baptisteArno/typebot.io/releases. Take both backup
artifacts first, then edit both `image:` lines in /srv/typebot/compose.yml to the new tag and its
digest, keeping the builder and the viewer on the same version.

```bash
cd /srv/typebot
docker compose pull
docker compose up -d
docker compose logs --tail 40 builder
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tags and digests back and run the same three commands. Then re-run the
five checks from step 7 before you call the update done. Leave the postgres tag alone unless a
release note says otherwise; a major PostgreSQL bump wants its own upgrade path.

## 10. What will probably go wrong

The sign-in code will not arrive and nothing will look broken. I sat on the login-code screen with
three healthy containers, a `200` from the providers endpoint and an empty inbox, because the
relay had refused the message out of sight: Typebot only logs `Magic link email could not be sent`
when the send itself throws. Read `docker compose logs --tail 60 builder` first, since a rejected
from-address or a failed relay login shows up there. Mine was the from-address, on a domain the
relay had not verified. Check spam second, and only then suspect the install.

## 11. Out of scope

- Do not configure Google, GitHub, GitLab, Facebook, Azure AD or Keycloak sign-in. Each needs a
  client registered in somebody else's console; this install signs people in by email.
- Do not add S3 storage or a MinIO container. Media uploads inside bots want an object store on a
  third hostname, and an unset `S3_ACCESS_KEY` switches those blocks off.
- Do not add upstream's Redis container. It buys a per-IP rate limit on sign-ins.
- Do not install a mail server here. Use the relay from step 3; port 25 on a fresh VPS is a fight
  with no prize.
````

## 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 Typebot 3.17.2 and its PostgreSQL under ~/selfhost/typebot: the builder at
http://localhost:8177, the viewer at http://localhost:8977.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Every bot this publishes lives at http://localhost:8977 and an id, which means "this
computer" wherever it is read, so a link sent to a colleague or opened on their own phone resolves
to nothing. They get a private place to design and test flows, not a bot others can talk to.

Then the awkward one: Typebot registers no sign-in method until a mail relay is configured, and
that holds here. The user needs a host, port, username, password and a from-address from a
transactional provider before step 4. Tell them now.

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
distribution ID and codename print next, for step 2. Typebot plus PostgreSQL needs 2048 MB of RAM
available and 15 GB free on the home disk, because the two application images are over a gigabyte
each compressed. All three publish amd64 and arm64. Under either floor, print both 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 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/typebot/backups
ls -la ~/selfhost/typebot
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: bots, results and
credentials are rows in PostgreSQL, which step 5 keeps in a Docker volume, so no ownership
fix is needed anywhere.

## 4. Secrets

Two secrets: the PostgreSQL password and `ENCRYPTION_SECRET`. Generate both here, print neither,
keep both out of your summary and any log line. Upstream documents `openssl rand -base64 24` for
`ENCRYPTION_SECRET`; its schema rejects anything that is not exactly 32 characters, which is what
24 bytes of base64 give.

```bash
cd ~/selfhost/typebot
umask 077
cat > .env <<EOF
NEXTAUTH_URL=http://localhost:8177
NEXT_PUBLIC_VIEWER_URL=http://localhost:8977
NODE_OPTIONS=--no-node-snapshot
DISABLE_SIGNUP=true
DEFAULT_WORKSPACE_PLAN=UNLIMITED
ENCRYPTION_SECRET=$(openssl rand -base64 24)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
ADMIN_EMAIL=CHANGE_ME
NEXT_PUBLIC_SMTP_FROM=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 .env
umask 022
ls -l .env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere; on
Windows the mode bits are advisory and the real boundary is the user's account.
`DISABLE_SIGNUP` is true from the first boot, and upstream's sign-in callback lets exactly one
address past it: whatever is in `ADMIN_EMAIL`.

STOP: tell the user to open ~/selfhost/typebot/.env, replace every `CHANGE_ME`, put their own
address in `ADMIN_EMAIL`, correct `SMTP_PORT` if their relay is not 587, set `SMTP_SECURE=true` if
it is 465, and save. Do not continue until they confirm, and never ask them to paste a value.

```bash
grep -c CHANGE_ME .env || true
```

Assert: that prints `0`. It counts lines, never values.

## 5. compose.yml

```bash
cat > ~/selfhost/typebot/compose.yml <<'EOF'
# Typebot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .. https://docs.typebot.com/self-hosting/deploy/docker
#   configuration ... https://docs.typebot.com/self-hosting/configuration
#
# Three services, paths relative to ~/selfhost/typebot/ so one file works on
# macOS, Linux and Windows. Builder on 8177, viewer on 8977: two Next.js
# servers each owning the root path, and only the builder migrates the
# database. PostgreSQL keeps its data in a named volume, not a bind mount,
# because the image chowns that directory to a uid Docker Desktop cannot grant
# on a Windows home directory. Digests read 2026-08-07, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - typebot-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U typebot -d typebot"]
      interval: 10s
      retries: 12

  builder:
    image: baptistearno/typebot-builder:3.17.2@sha256:a67edf944eb64e885a3660d8bbd11102b9d468d31dbf4b7f6170e4cd2ceaa9d3
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/auth/providers').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      retries: 24
      start_period: 120s
    ports:
      # Loopback only: no other device on the wifi can reach 8177.
      - "127.0.0.1:8177:3000"
    depends_on:
      postgres:
        condition: service_healthy

  viewer:
    image: baptistearno/typebot-viewer:3.17.2@sha256:70f1dd949f2246432650cfda082c01e45089fb129369ceee6632d57b9c5f2b7e
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    ports:
      # Loopback only: no other device on the wifi can reach 8977.
      - "127.0.0.1:8977:3000"
    depends_on:
      builder:
        condition: service_healthy

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

Assert: that prints `compose OK`. Three services, two ports, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. No hostname, so
nothing to resolve. No certificate, because one attests a public name and nothing here has one;
browsers treat http://localhost as a secure context anyway, so pages needing crypto still work.
No firewall rule: nothing is published beyond loopback.

8177 and 8977 are bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on
the same wifi, not anyone on the internet. Confirm it:

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

Assert: that prints `2`, one port each for the builder and the viewer. PostgreSQL publishes no
host port, so 5432 cannot appear.

## 7. Start and verify

The first pull is over two gigabytes, and the builder runs migrations before it listens, so the
first boot takes minutes.

```bash
cd ~/selfhost/typebot
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8177/api/auth/providers); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8177/api/auth/providers
curl -sS http://localhost:8977/api/healthz
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8977/api/typebots
grep -c '^DISABLE_SIGNUP=true$' .env
```

Assert all five, and print what you got for each. The loop ends on `200`. The providers response
is JSON containing `"nodemailer"`, which proves step 4's relay settings were read. The viewer
answers `{"status":"ok"}`. The unauthenticated call to the viewer's API prints `401`,
upstream's answer to a request with no bearer token. The grep prints `1`. If any misses, stop, run
`docker compose logs --tail 60 builder` and name the likely cause: `Invalid environment variables`
points at step 4, where an `ENCRYPTION_SECRET` that is not exactly 32 characters stops the process
before it listens; `{}` from providers means `NEXT_PUBLIC_SMTP_FROM` is empty. If `port is
already allocated` came back, find what holds it (`lsof -nP -iTCP:8177 -sTCP:LISTEN`, or
`netstat -ano | findstr :8177` on Windows) and stop until they free it. A running container is not
success.

The first screen at http://localhost:8177/signin is headed `Sign In`, with `Don't have an account?`
under it and one box asking for an email address next to a `Submit` button.

STOP: tell the user to open http://localhost:8177/signin, enter the address from `ADMIN_EMAIL`,
and type in the six-digit code Typebot mails to it. Do not continue until they confirm they see an
empty bot list. That code arriving is the only proof the relay works; if nothing lands in two
minutes, read step 10.

## 8. First backup and restore

Two artifacts: a dump with every bot, result and stored credential, and an archive of the two
files that rebuild the service.

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

Assert: both exist and are non-empty. Print both sizes. Nothing is stopped: `pg_dump` snapshots a
running database.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy both there with `cp`. In Git Bash a Windows
drive is `/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are there. If
not, say plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/typebot`, untar the archive there first so compose.yml
and .env are back before any container starts: PostgreSQL takes `POSTGRES_PASSWORD` from .env the
moment it initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs
because it drops the old volume on purpose, `docker compose up -d postgres`, wait 30 seconds, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U typebot -d typebot`,
then `docker compose up -d`. `ENCRYPTION_SECRET` decrypts the provider keys in that dump, so a
restore beside a fresh secret is unreadable.

## 9. Updating later

New versions are listed at https://github.com/baptisteArno/typebot.io/releases. Take both backups
first, then edit both `image:` lines in ~/selfhost/typebot/compose.yml to the new tag and digest,
builder and viewer on one version:

```bash
cd ~/selfhost/typebot
docker compose pull
docker compose up -d
docker compose logs --tail 40 builder
```

Watch that log until it settles, then re-run step 7's checks.

## 10. What will probably go wrong

I rebooted this machine, opened the builder, and got a connection error that read like a lost
database. It was not: Docker Desktop had not started with the session, so nothing listened on
8177 or 8977, and `restart: unless-stopped` acts only once the daemon is up. Turn on Docker
Desktop's start-at-login setting, then after a reboot run
`cd ~/selfhost/typebot && docker compose up -d` before concluding anything is broken. The other
one is the sign-in code not arriving, the relay refusing it out of sight: read
`docker compose logs --tail 60 builder`, then the spam folder.

## 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 rebind 8177 or 8977 to 0.0.0.0 so a phone can reach them. That puts a bot builder and
  everything typed into it on every network the user joins.
- Do not configure Google, GitHub, GitLab or Azure AD sign-in. Each needs a client registered in
  somebody else's console; this install signs people in by email.
- Do not add S3 storage or MinIO. Media uploads inside bots stay off here.
````

## docker-compose.yml

```yaml
# Typebot · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://docs.typebot.com/self-hosting/deploy/docker
#   configuration ... https://docs.typebot.com/self-hosting/configuration
#
# Three services: builder, viewer, and the PostgreSQL holding both. Builder and
# viewer are Next.js servers that each own the root path, so each needs its own
# hostname and its own loopback port, and only the builder migrates the
# database. Neither image ships curl, so the health checks use node. Digests
# read 2026-08-07, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/typebot/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U typebot -d typebot"]
      interval: 10s
      retries: 12

  builder:
    image: baptistearno/typebot-builder:3.17.2@sha256:a67edf944eb64e885a3660d8bbd11102b9d468d31dbf4b7f6170e4cd2ceaa9d3
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/auth/providers').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      retries: 24
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8177.
      - "127.0.0.1:8177:3000"
    depends_on:
      postgres:
        condition: service_healthy

  viewer:
    image: baptistearno/typebot-viewer:3.17.2@sha256:70f1dd949f2246432650cfda082c01e45089fb129369ceee6632d57b9c5f2b7e
    restart: unless-stopped
    env_file: /srv/typebot/.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    ports:
      # Loopback only: Caddy is the only thing that reaches 8977.
      - "127.0.0.1:8977:3000"
    depends_on:
      builder:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Typebot · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .. https://docs.typebot.com/self-hosting/deploy/docker
#   configuration ... https://docs.typebot.com/self-hosting/configuration
#
# Three services, paths relative to ~/selfhost/typebot/ so one file works on
# macOS, Linux and Windows. Builder on 8177, viewer on 8977: two Next.js
# servers each owning the root path, and only the builder migrates the
# database. PostgreSQL keeps its data in a named volume, not a bind mount,
# because the image chowns that directory to a uid Docker Desktop cannot grant
# on a Windows home directory. Digests read 2026-08-07, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - typebot-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U typebot -d typebot"]
      interval: 10s
      retries: 12

  builder:
    image: baptistearno/typebot-builder:3.17.2@sha256:a67edf944eb64e885a3660d8bbd11102b9d468d31dbf4b7f6170e4cd2ceaa9d3
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/auth/providers').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      retries: 24
      start_period: 120s
    ports:
      # Loopback only: no other device on the wifi can reach 8177.
      - "127.0.0.1:8177:3000"
    depends_on:
      postgres:
        condition: service_healthy

  viewer:
    image: baptistearno/typebot-viewer:3.17.2@sha256:70f1dd949f2246432650cfda082c01e45089fb129369ceee6632d57b9c5f2b7e
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://typebot:${POSTGRES_PASSWORD}@postgres:5432/typebot
    ports:
      # Loopback only: no other device on the wifi can reach 8977.
      - "127.0.0.1:8977:3000"
    depends_on:
      builder:
        condition: service_healthy

volumes:
  typebot-pgdata:
```

## Caddyfile

```text
# Typebot · the Caddy site blocks for this service.
#
# Authored by caniselfhostit from
# https://docs.typebot.com/self-hosting/deploy/docker and
# https://caddyserver.com/docs/automatic-https
#
# Two site blocks, because Typebot is two applications that cannot share a
# hostname. Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the
# hostname pointed at this box; bot.<DOMAIN> needs its own A record on the
# same address.

<DOMAIN> {
	# The builder. Sign-in codes land here and every bot design sits behind
	# that session, so nothing on this name should be framed by another site.
	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

	# 8177 is a loopback port, not a container port, and not in the firewall.
	reverse_proxy 127.0.0.1:8177
}

bot.<DOMAIN> {
	# The viewer, embedded in other people's pages on purpose, so no frame
	# restriction. An AI block streams, so this route flushes every write.
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	reverse_proxy 127.0.0.1:8977 {
		flush_interval -1
	}
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Typebot · 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=typebot.example.com \
#   ADMIN_EMAIL=you@example.com \
#   SMTP_FROM=notifications@example.com \
#   SMTP_HOST=smtp.example.com SMTP_USERNAME=apikey SMTP_PASSWORD=... ./install.sh
#
# SMTP_PASSWORD is read from the environment and never written to your terminal.
# Start that command line with a space if your shell records history.
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.typebot.com/self-hosting/deploy/docker
#   https://docs.typebot.com/self-hosting/configuration
#
# Typebot is two applications. The builder answers on DOMAIN_HOST and the viewer
# on bot.DOMAIN_HOST; both are Next.js servers that own the root path, so both
# names need an A record on this box before you run this.
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# ENCRYPTION_SECRET. Both go into /srv/typebot/.env with mode 600, and neither
# is ever printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/typebot}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
SMTP_FROM="${SMTP_FROM:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_SECURE="${SMTP_SECURE:-false}"
SMTP_USERNAME="${SMTP_USERNAME:-}"
SMTP_PASSWORD="${SMTP_PASSWORD:-}"

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 builder hostname you pointed at this server, e.g. typebot.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL; it is the only address allowed to create an account on this instance"
[ -n "$SMTP_FROM" ] || die "set SMTP_FROM; without it Typebot registers no sign-in method at all"
[ -n "$SMTP_HOST" ] || die "set SMTP_HOST; sign-in codes arrive by mail and there is no other way in"
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 2048 ] || die "only ${avail_mb} MB of RAM available; two Next.js servers plus PostgreSQL want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 15 ] || die "only ${avail_gb} GB free on /srv; the two application images want 15 GB"

for host in "$DOMAIN_HOST" "bot.${DOMAIN_HOST}"; do
	resolved="$(getent hosts "$host" | awk '{print $1; exit}' || true)"
	[ -n "$resolved" ] || die "$host does not resolve yet. Add the A record, wait a minute, run this again."
done

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
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 two secrets, on the server ------------------------------
#
# Upstream documents `openssl rand -base64 24` for ENCRYPTION_SECRET and its
# schema rejects anything that is not exactly 32 characters, which is what 24
# random bytes of base64 come to. The database password is hex, so it needs no
# escaping inside a connection string. Read them later with
#   sudo grep -E 'POSTGRES_PASSWORD|ENCRYPTION_SECRET' /srv/typebot/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		NEXTAUTH_URL=https://${DOMAIN_HOST}
		NEXT_PUBLIC_VIEWER_URL=https://bot.${DOMAIN_HOST}
		NODE_OPTIONS=--no-node-snapshot
		DISABLE_SIGNUP=true
		DEFAULT_WORKSPACE_PLAN=UNLIMITED
		ENCRYPTION_SECRET=$(openssl rand -base64 24)
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		ADMIN_EMAIL=${ADMIN_EMAIL}
		NEXT_PUBLIC_SMTP_FROM=${SMTP_FROM}
		SMTP_HOST=${SMTP_HOST}
		SMTP_PORT=${SMTP_PORT}
		SMTP_SECURE=${SMTP_SECURE}
		SMTP_USERNAME=${SMTP_USERNAME}
		SMTP_PASSWORD=${SMTP_PASSWORD}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site blocks, on the host ---------------------------------------
#
# Two blocks, one per application. The template carries <DOMAIN> twice; the sed
# below substitutes the live hostname, and the archive in step 7 keeps that
# substituted copy rather than the template.

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-typebot"
	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 neither 8177, 8977 nor 5432 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; 8177, 8977 and 5432 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 builder applies the Prisma migrations before it listens, and the viewer
# waits for the builder to report healthy, so the first boot takes minutes.

docker compose pull
docker compose up -d

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

# The email sign-in provider only registers when NEXT_PUBLIC_SMTP_FROM is set.
providers="$(curl -sS "https://${DOMAIN_HOST}/api/auth/providers" || true)"
case "$providers" in
	*nodemailer*) : ;;
	*) die "the builder is up but registered no email sign-in provider. Check NEXT_PUBLIC_SMTP_FROM in $APP_DIR/.env" ;;
esac

viewer="$(curl -sS "https://bot.${DOMAIN_HOST}/api/healthz" || true)"
case "$viewer" in
	*'"status":"ok"'*) : ;;
	*) die "https://bot.${DOMAIN_HOST}/api/healthz answered ${viewer:-nothing}. Check: docker compose logs --tail 40 viewer" ;;
esac

# The viewer API must refuse a call with no bearer token.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://bot.${DOMAIN_HOST}/api/typebots" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated viewer API call returned ${unauth}, not 401. Stop and investigate."

# Registration is closed from the first boot; ADMIN_EMAIL is the one way in.
grep -q '^DISABLE_SIGNUP=true$' "$APP_DIR/.env" || die "DISABLE_SIGNUP is not true in $APP_DIR/.env"

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

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

cat <<-DONE

	Typebot is answering at https://${DOMAIN_HOST}/signin

	  1. Sign in at https://${DOMAIN_HOST}/signin with ${ADMIN_EMAIL}. Typebot
	     mails you a six-digit code; that code arriving is the only proof your
	     relay works. If it does not arrive, read
	       docker compose logs --tail 60 builder
	     and check the spam folder before suspecting the install.
	  2. Registration is closed already. DISABLE_SIGNUP is true from the first
	     boot and upstream's sign-in callback lets exactly one address past it,
	     the one in ADMIN_EMAIL, so there is nothing to close later.
	  3. Published bots answer on https://bot.${DOMAIN_HOST}, a different name
	     from the builder because each is a Next.js server owning the root path.
	     That is the address that goes into the links and embeds you hand out.
	  4. Your two secrets are in $APP_DIR/.env, mode 600, and were not printed
	     here. ENCRYPTION_SECRET is what decrypts the provider keys stored in
	     the database, so back the .env up with the dump, not apart from it.
	  5. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. 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/chatbase/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
