# Can I self-host Kit?

**YES, BUT** — it's called Keila. ONE WEEKEND setup · ~4 hours to running · 1 GB RAM minimum · $39/mo you stop paying ($468/yr on the Creator plan).

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

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

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` is the hostname whose A record already points here, and it becomes
`URL_HOST`: unsubscribe links, form URLs and opt-in confirmations are built from it, so changing
it later breaks links already in somebody's inbox. `<ADMIN_EMAIL>` is what the root account logs
in with.

Ask a third question in the same breath: do they have an SMTP relay account, with a host, port,
username, password and a from-address on a domain they control. Keila delivers nothing itself,
and unlike most apps it will not start without one: upstream reads the relay host, from-address
and password while the release boots, and halts when any is missing.

Keila plus PostgreSQL needs 1024 MB of RAM available and 5 GB free on /srv. The Keila image is
published for linux/amd64 only. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If the architecture is anything but `amd64`, print it and stop: there is
no arm64 image to fall back to. If `dig +short` prints nothing, print that and stop.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user, `postgres` at mode `700` owned by
root, and `uploads` at `755`. Leave the last two alone: PostgreSQL chowns its data directory to
the uid it runs as, and the Keila image declares no `USER`, so the release runs as root and
writes campaign images there.

## 3. Secrets

Three secrets, generated here: the PostgreSQL password, the Phoenix secret key base and the
root account's password. Do not print any of them, do not repeat them in your summary, and do
not put them in any log line.

```bash
umask 077
cat > /srv/keila/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 48)
URL_HOST=<DOMAIN>
KEILA_USER=<ADMIN_EMAIL>
KEILA_PASSWORD=$(openssl rand -base64 30)
MAILER_SMTP_PORT=587
MAILER_ENABLE_STARTTLS=true
MAILER_SMTP_HOST=
MAILER_SMTP_USER=
MAILER_SMTP_FROM_EMAIL=
MAILER_SMTP_PASSWORD=
# the four blank lines above are the relay account, filled in by hand
EOF
chmod 600 /srv/keila/.env
umask 022
ls -l /srv/keila/.env
```

Assert: the file exists with mode `-rw-------`. Hex for the database password and for the key
base, which upstream wants at least 64 characters long and this is 96; base64 for the root
password, which a human types into a form. The seed that creates the root account reads
`KEILA_USER` and `KEILA_PASSWORD` on the first start against an empty database. Left unset,
upstream invents a random password and writes it into the container log in clear text, where it
sits in `docker compose logs` forever. The relay lines are
empty on purpose: those values are the user's, not this prompt's.

STOP: tell the user to fill the relay settings in themselves and wait. Do not continue until
they confirm. Tell them to run `sudo nano /srv/keila/.env` and fill in the four blank lines at
the bottom: the relay hostname on `MAILER_SMTP_HOST`, the account name on `MAILER_SMTP_USER`, a
sending address on a domain they control on `MAILER_SMTP_FROM_EMAIL`, and the relay password on
`MAILER_SMTP_PASSWORD`. Port 587 with STARTTLS is already set and is what most relays want; one
documenting 465 wants that port, `MAILER_ENABLE_STARTTLS=false` and `MAILER_ENABLE_SSL=true`.
Tell them not to paste any of it back to you.

## 4. compose.yml

```bash
cat > /srv/keila/compose.yml <<'EOF'
# Keila · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://www.keila.io/docs/installation
#   configuration ...... https://www.keila.io/docs/configuration
#   root user seed ..... https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Two services: Keila and the PostgreSQL it keeps contacts, campaigns and forms
# in. Upstream states PostgreSQL is the only dependency besides a container
# engine, and the release migrates and seeds itself on the way up. The SMTP relay
# is not optional: upstream reads the relay host, from-address and password at
# boot and halts when one is missing. Digests read on 2026-08-06. The Keila image
# publishes linux/amd64 only; PostgreSQL publishes both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

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

  keila:
    image: pentacent/keila:0.30.2@sha256:b2fdb45228c94a0df0d7d1597009edaa663ff455999ddcf1dc1483d06631762b
    container_name: keila
    restart: unless-stopped
    env_file: /srv/keila/.env
    environment:
      # Assembled from .env, so the two cannot disagree.
      DB_URL: postgres://keila:${POSTGRES_PASSWORD}@db:5432/keila
      # The release listens on 4000; the host port below is the only way in.
      PORT: "4000"
      # Caddy terminates TLS, so Keila has to be told the links it writes into
      # campaigns and forms are https. Upstream then defaults URL_PORT to 443.
      URL_SCHEMA: https
      # /auth/register is an open sign-up form on a public hostname otherwise.
      DISABLE_REGISTRATION: "true"
      # HOME here is /opt/app, and uploads default to a path under it.
      USER_CONTENT_DIR: /opt/app/uploads
      # The digest above is the pin; a check from inside cannot act on it.
      DISABLE_UPDATE_CHECKS: "true"
    volumes:
      - /srv/keila/uploads:/opt/app/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8145.
      - "127.0.0.1:8145:4000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/keila && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Compose reads /srv/keila/.env twice: `env_file` hands it to
the container, and `${POSTGRES_PASSWORD}` above is substituted from it because .env sits here.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. The copy on line one matters: a syntax error takes down every other site.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-keila
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Keila · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.keila.io/docs/installation 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
# URL_HOST in .env: Keila builds unsubscribe links, form URLs and opt-in
# confirmation links from that setting, not from the request, so the two have to
# agree. Upstream asks that a reverse proxy forward WebSockets, which Caddy's
# reverse_proxy does with no extra configuration.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN, the value Keila's own browser pipeline already sets
		# through Phoenix's secure-headers plug. Stated here so it does not
		# depend on that plug staying in the pipeline.
		X-Frame-Options "SAMEORIGIN"
		# An unsubscribe or confirmation URL carries a recipient token in
		# the path, and a full Referer would hand it to the next site.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8145 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8145
}
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-keila, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it itself; nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero already configured.

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

80/tcp answers the ACME challenge and redirects, 443/tcp is the only way in, 443/udp is HTTP/3.
8145 stays closed because compose binds it to 127.0.0.1, and 5432 because compose publishes no
host port at all. Nothing opens for mail: the relay connection is outbound, which default-deny
already permits. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8145 or 5432.

## 7. Start and verify

Check first that step 3's relay lines were filled in. Upstream halts the release when one is
absent; a blank one is worse, because the service starts and cannot send. This prints key names
and whether each has a value, never a value:

```bash
sudo awk -F= '/^MAILER_SMTP_(HOST|USER|PASSWORD|FROM_EMAIL)=/ {print $1 "=" (length($2) ? "set" : "EMPTY")}' /srv/keila/.env
```

Assert: four lines, all reading `set`. If any reads `EMPTY`, stop and send the user back to step
3. Then start:

```bash
cd /srv/keila
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/auth/login | grep -o 'Sign in with your email address and password here.'
curl -sS https://<DOMAIN>/auth/register | grep -o 'Registration disabled.'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/contacts
```

Assert all four and print what you received for each. The loop ends printing `200`. The first
grep prints `Sign in with your email address and password here.`, the line under the heading on
the first screen. The second prints `Registration disabled.`, the security assert here: the
sign-up form is shut, so a public hostname is not handing accounts to strangers. The last prints `403`, what upstream's API authorization plug returns to a caller
with no bearer token. If any of the four misses, stop, run
`docker compose logs --tail 40 keila` and `docker compose logs --tail 20 db`, and name the
likely cause: a missing-mailer-variable line is step 3 unfinished, a database that never reports
healthy is step 2, a `502` while the container is up is step 5. A running container is not
success.

STOP: tell the user to read their root password with
`sudo grep KEILA_PASSWORD /srv/keila/.env`, put it in their password manager, log in at
https://<DOMAIN>/auth/login as `<ADMIN_EMAIL>`, and wait. Do not continue until they confirm
they are inside. The next screen asks for a project, and a sender has to be added inside it
before any campaign can go out: the relay in .env carries system mail only.

## 8. First backup and restore

Two artifacts. The database holds contacts, their consent, campaigns, forms and click history.
The archive holds the config, the uploaded images and the host's Caddy site block.

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

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently. Tell the user what they will not guess: the
archive contains .env, with the relay password and the key base in it, so treat it like a
password-manager export.

A backup on the same disk is not a backup. Run this from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/keila/postgres`, recreate it as in step 2,
untar the archive back into /srv/keila, `docker compose up -d db`, wait about 30 seconds for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U keila -d keila`, then `docker compose up -d` and re-run step
7's asserts. Say what is at stake: who opted in and when is a row in that database, and a list
restored from nothing is a list the user may no longer mail.

## 9. Updating later

New versions are at https://github.com/pentacent/keila/releases. Back up first, then edit the
image line in /srv/keila/compose.yml to the new tag and digest:

```bash
cd /srv/keila
docker compose pull
docker compose up -d
docker compose logs --tail 30 keila
```

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

## 10. What will probably go wrong

Mail, and not the install. I had Keila answering on its hostname in twenty minutes and spent the
rest of the afternoon on the sending path, because there are two mail settings here that look
like one. The relay in .env carries system mail: password resets and opt-in
confirmations. The sender added inside a project is what campaigns go out as, and it lives in the
web interface, not in that file. An install with the first right and the second missing looks
healthy and sends nothing. Add a sender, mail one campaign to the user's own address, and open
what arrives before anybody else is imported.

## 11. Out of scope

- Do not import a contact list before a test campaign has arrived in a real inbox. A list
  imported into an instance that cannot send gets imported twice.
- Do not configure hCaptcha or Friendly Captcha keys. Those protect a sign-up form this install
  has switched off.
- Do not add AWS SES, Mailgun or Postmark as a second sending path, and do not serve uploads
  from a second hostname. Each is a second thing to keep working.
````

## 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 Keila 0.30.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, replace `<DOMAIN>` with the hostname whose A record already points at the box,
and replace `<ADMIN_EMAIL>` with the address your admin account will log in with.

Read this before step 1. Keila does not deliver mail; it hands finished campaigns to an SMTP
relay you sign up for. Unlike most apps it will not even start without one, because upstream
reads the relay host, the from-address and the relay password out of the environment while the
release boots and stops the process when any of the three is missing. Have a relay account, with
a host, a port, a username, a password and a from-address on a domain you control, open in
another tab before you begin. And pick `<DOMAIN>` as a hostname you intend to keep: it becomes
`URL_HOST`, which is what every unsubscribe link and opt-in confirmation link in every message
you send is built from.

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

If you do not: `arm64` is a full stop rather than a slow path. The Keila image is published for
linux/amd64 only, so on an arm64 VPS there is nothing to pull and no fallback tag; rebuild the
box on an amd64 plan. An empty last line means the A record does not exist yet, so add it, wait
a minute and run `dig +short <DOMAIN>` again: Caddy cannot get a certificate for a name that
does not resolve, and failed attempts count against a rate limit you cannot see.

## 2. Layout

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

You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`uploads` at `drwxr-xr-x`.

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. `uploads` stays root-writable because the Keila image declares no `USER`,
so the release inside it runs as root and writes campaign images there.

## 3. Secrets

Three secrets, all generated here on the server: the PostgreSQL password, the Phoenix secret key
base, and your admin password. Replace `<DOMAIN>` and `<ADMIN_EMAIL>` on lines three and four
before you paste, and paste the whole block at once.

```bash
umask 077
cat > /srv/keila/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 48)
URL_HOST=<DOMAIN>
KEILA_USER=<ADMIN_EMAIL>
KEILA_PASSWORD=$(openssl rand -base64 30)
MAILER_SMTP_PORT=587
MAILER_ENABLE_STARTTLS=true
MAILER_SMTP_HOST=
MAILER_SMTP_USER=
MAILER_SMTP_FROM_EMAIL=
MAILER_SMTP_PASSWORD=
# the four blank lines above are the relay account, filled in by hand
EOF
chmod 600 /srv/keila/.env
umask 022
ls -l /srv/keila/.env
```

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

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens when
the lines are pasted separately into different shells. Run `chmod 600 /srv/keila/.env` and carry
on. If the file already existed from an earlier attempt, this block has now replaced all three
secrets, 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 produces an authentication
failure in the Keila log rather than anything that mentions passwords.

Do not paste that file, any of the three values, or any command output containing them into this
chat window. The relay password you are about to add is the one that would cost you most: it can
send mail as you.

Now fill in the relay. Run `sudo nano /srv/keila/.env` and complete the four blank lines at the
bottom: the relay hostname on `MAILER_SMTP_HOST`, the account name on `MAILER_SMTP_USER`, a
sending address on a domain you control on `MAILER_SMTP_FROM_EMAIL`, and the relay password on
`MAILER_SMTP_PASSWORD`. Port 587 with STARTTLS is set already and is what most relays want; a
relay that documents 465 wants that port, `MAILER_ENABLE_STARTTLS=false` and a line reading
`MAILER_ENABLE_SSL=true`. Save, then check the four are populated without printing them:

```bash
sudo awk -F= '/^MAILER_SMTP_(HOST|USER|PASSWORD|FROM_EMAIL)=/ {print $1 "=" (length($2) ? "set" : "EMPTY")}' /srv/keila/.env
```

You should see: four lines, every one of them ending in `set`.

If you do not: an `EMPTY` means that line is still blank. A blank relay setting does not stop
the container, it starts a Keila that cannot send anything, which is harder to see than a crash.
Open the file again. A key that does not appear at all means the line was deleted, and that one
does stop the release at boot; add it back exactly as spelled above.

## 4. compose.yml

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

```bash
cat > /srv/keila/compose.yml <<'EOF'
# Keila · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://www.keila.io/docs/installation
#   configuration ...... https://www.keila.io/docs/configuration
#   root user seed ..... https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Two services: Keila and the PostgreSQL it keeps contacts, campaigns and forms
# in. Upstream states PostgreSQL is the only dependency besides a container
# engine, and the release migrates and seeds itself on the way up. The SMTP relay
# is not optional: upstream reads the relay host, from-address and password at
# boot and halts when one is missing. Digests read on 2026-08-06. The Keila image
# publishes linux/amd64 only; PostgreSQL publishes both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

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

  keila:
    image: pentacent/keila:0.30.2@sha256:b2fdb45228c94a0df0d7d1597009edaa663ff455999ddcf1dc1483d06631762b
    container_name: keila
    restart: unless-stopped
    env_file: /srv/keila/.env
    environment:
      # Assembled from .env, so the two cannot disagree.
      DB_URL: postgres://keila:${POSTGRES_PASSWORD}@db:5432/keila
      # The release listens on 4000; the host port below is the only way in.
      PORT: "4000"
      # Caddy terminates TLS, so Keila has to be told the links it writes into
      # campaigns and forms are https. Upstream then defaults URL_PORT to 443.
      URL_SCHEMA: https
      # /auth/register is an open sign-up form on a public hostname otherwise.
      DISABLE_REGISTRATION: "true"
      # HOME here is /opt/app, and uploads default to a path under it.
      USER_CONTENT_DIR: /opt/app/uploads
      # The digest above is the pin; a check from inside cannot act on it.
      DISABLE_UPDATE_CHECKS: "true"
    volumes:
      - /srv/keila/uploads:/opt/app/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8145.
      - "127.0.0.1:8145:4000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/keila && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/keila/.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/keila/compose.yml` and paste again in one go. Note what this file does with
/srv/keila/.env twice over: `env_file` hands the whole thing to the container, and the
`${POSTGRES_PASSWORD}` in the two lines above is substituted by Compose itself, because .env sits
in the project directory. One password, written once, used by the database and by the connection
string that reaches it.

## 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-keila
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Keila · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.keila.io/docs/installation 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
# URL_HOST in .env: Keila builds unsubscribe links, form URLs and opt-in
# confirmation links from that setting, not from the request, so the two have to
# agree. Upstream asks that a reverse proxy forward WebSockets, which Caddy's
# reverse_proxy does with no extra configuration.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN, the value Keila's own browser pipeline already sets
		# through Phoenix's secure-headers plug. Stated here so it does not
		# depend on that plug staying in the pipeline.
		X-Frame-Options "SAMEORIGIN"
		# An unsubscribe or confirmation URL carries a recipient token in
		# the path, and a full Referer would hand it to the next site.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8145 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8145
}
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-keila /etc/caddy/Caddyfile`, reload, and
paste again. Caddy terminates TLS and speaks plain http to the container, which is why
`URL_SCHEMA` is `https` in the compose file: without it Keila would write `http://` links into
messages for a service only reachable over https.

## 6. Firewall

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

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8145` or `5432`.

If you do not: delete anything for `8145` or `5432` with `sudo ufw delete allow 8145`. 8145 is
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 firewall rule could apply to. Nothing opens for mail either: the relay connection is
outbound, which the default-deny policy already allows. `Status: inactive` is a different
problem, because Prompt Zero left this firewall enabled, so `sudo ufw enable` before you go on.

## 7. Start and verify

```bash
cd /srv/keila
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/auth/login | grep -o 'Sign in with your email address and password here.'
curl -sS https://<DOMAIN>/auth/register | grep -o 'Registration disabled.'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/contacts
```

You should see, in order: the loop reaching `200`, then the line
`Sign in with your email address and password here.`, then `Registration disabled.`, then `403`.

If you do not: the second and third lines are the ones worth understanding. The first is the
sentence under the heading on the first screen, so seeing it means Caddy, the container and the
database are all doing their jobs. The second means the sign-up form at `/auth/register` is shut,
which matters because this hostname is public and an open form there would let strangers create
accounts on your server. The `403` is the API refusing a call with no bearer token. If the loop
never reaches `200`, run `docker compose logs --tail 20 db` first, because a database that never
reports healthy stops everything behind it, then `docker compose logs --tail 40 keila`: a line
there about a missing mailer variable means one of step 3's four relay values is still blank, and
the container will keep exiting until it is filled in. A running container is not success.

The first screen is at https://<DOMAIN>/auth/login and shows the heading `Sign in.` above that
sentence.

Now log in. Read your admin password once with `sudo grep KEILA_PASSWORD /srv/keila/.env`, put it
in your password manager, and sign in at https://<DOMAIN>/auth/login with the address you used as
`<ADMIN_EMAIL>`. The next screen asks you to create a project. Inside that project, add a sender
before you write anything: the relay in .env carries system mail, and the sender is what
campaigns actually go out as. They are two settings and they look like one.

## 8. First backup and restore

Two artifacts. The database holds contacts, their consent, campaigns, forms and click history.
The archive holds the config that rebuilds the service around it, the uploaded images, and the
host's Caddy site block.

```bash
cd /srv/keila
docker compose exec -T db pg_dump -U keila -d keila | gzip > /srv/keila/backups/keila-db-$(date +%F).sql.gz
sudo tar -czf /srv/keila/backups/keila-files-$(date +%F).tar.gz -C /srv/keila compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/keila/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. Treat
both files as credentials once they exist: the archive contains .env, with the relay password and
the key base in it.

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

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

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

```bash
cd /srv/keila
docker compose down
sudo rm -rf /srv/keila/postgres
sudo install -d -m 700 /srv/keila/postgres
docker compose up -d db
sleep 30
gunzip -c /srv/keila/backups/keila-db-$(date +%F).sql.gz | docker compose exec -T db psql -U keila -d keila
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/auth/login | grep -o 'Sign in with your email address and password here.'
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then that sentence again from the last
command, which means the login page survived a database that was deleted and rebuilt.

If you do not: `role "keila" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this step: who opted in and when is a row in that database, and a contact list restored from
a backup you never took is a list you are no longer allowed to mail.

## 9. Updating later

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

```bash
cd /srv/keila
docker compose pull
docker compose up -d
docker compose logs --tail 30 keila
```

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

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
four checks from step 7 before you call the update done, and send yourself one campaign as well,
because a login page that renders can still sit in front of a queue that has stopped moving.

## 10. What will probably go wrong

Mail, and not the install. I had Keila answering on its hostname in twenty minutes and spent the
rest of the afternoon on the sending path, because there are two mail settings here that look
like one. The relay in .env carries system mail: password resets and the opt-in confirmation. The
sender you add inside a project is what campaigns go out as, and it lives in the web interface,
not in that file. An install with the first right and the second missing looks healthy and sends
nothing. Add a sender, mail one campaign to your own address, and open what arrives before you
import anybody else.

## 11. Out of scope

- Do not import a contact list before a test campaign has arrived in a real inbox. A list
  imported into an instance that cannot send gets imported twice.
- Do not configure hCaptcha or Friendly Captcha keys. Those protect a sign-up form this install
  has switched off.
- Do not add AWS SES, Mailgun or Postmark as a second sending path, and do not serve uploads
  from a second hostname with `USER_CONTENT_BASE_URL`. Each is a second thing to keep working.
````

## 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 Keila 0.30.2 and the PostgreSQL it keeps contacts in, under ~/selfhost/keila, answering
at http://localhost:8145.

## 1. Preflight

Say this before step 2 runs. Keila here can
still hand campaigns to a relay, but every unsubscribe and confirmation link it writes begins
with http://localhost:8145, which to a recipient means their own computer. The honest use
here is a list the user builds and drafts against plus campaigns sent to themselves; mailing
anyone else hands them a message they cannot unsubscribe from.

Ask this too: do they have an SMTP relay account, with a host, port, username, password and a
from-address on a domain they control? Keila delivers nothing itself and will not start without
one: upstream reads those three at boot and halts when any is missing.

Detect the OS and measure:

```bash
uname -s
uname -m
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. Keila plus
PostgreSQL needs 1024 MB of RAM available and 5 GB free on the home disk; under either floor,
print both numbers and stop. The Keila image is published for linux/amd64 only: on an Apple
Silicon Mac, where `uname -m` prints `arm64`, Docker Desktop emulates it, slower but working,
and on an arm64 Linux machine there is no emulation layer by default, so print the architecture
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/keila/uploads ~/selfhost/keila/backups
ls -la ~/selfhost/keila
```

Assert: `ls -la` shows `uploads` and `backups`, both owned by the user. There is no `data`
folder: contacts, campaigns and forms are rows in PostgreSQL, in a Docker-managed volume.

## 4. Secrets

Three secrets, generated here: the PostgreSQL password, the Phoenix secret key base and the root
password. Print none of them, and keep all three out of your summary and any log.

```bash
umask 077
cat > ~/selfhost/keila/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 48)
URL_HOST=localhost
KEILA_USER=root@localhost
KEILA_PASSWORD=$(openssl rand -base64 30)
MAILER_SMTP_PORT=587
MAILER_ENABLE_STARTTLS=true
MAILER_SMTP_HOST=
MAILER_SMTP_USER=
MAILER_SMTP_FROM_EMAIL=
MAILER_SMTP_PASSWORD=
# the four blank lines above are the relay account, filled in by hand
EOF
chmod 600 ~/selfhost/keila/.env
umask 022
ls -l ~/selfhost/keila/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these run the same
everywhere. The seed creating the root account reads `KEILA_USER` and `KEILA_PASSWORD` on the
first start against an empty database; left unset it invents one and writes it into the
container log in clear text. On Windows those mode bits are advisory: NTFS does not enforce
them, and the user's own account is the real boundary.

STOP: tell the user to fill the relay settings in themselves and wait. Do not continue until
they confirm. Tell them to open ~/selfhost/keila/.env in a text editor and fill in the four
blank lines at the bottom: the relay hostname on `MAILER_SMTP_HOST`, the account name on
`MAILER_SMTP_USER`, a sending address on a domain they control on `MAILER_SMTP_FROM_EMAIL`, and
the password on `MAILER_SMTP_PASSWORD`. Port 587 with STARTTLS is set already; a relay
documenting 465 wants that port, `MAILER_ENABLE_STARTTLS=false` and `MAILER_ENABLE_SSL=true`.

## 5. compose.yml

```bash
cat > ~/selfhost/keila/compose.yml <<'EOF'
# Keila · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://www.keila.io/docs/installation
#   configuration ...... https://www.keila.io/docs/configuration
#   root user seed ..... https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Two services, every path relative to ~/selfhost/keila/. The database is a
# named volume, not a bind mount: the PostgreSQL image chowns its data
# directory to its own uid, which a home-directory bind mount cannot allow on
# Windows. The SMTP relay is not optional: upstream reads the relay host,
# from-address and password at boot and halts when one is missing. Digests read
# 2026-08-06; Keila is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

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

  keila:
    image: pentacent/keila:0.30.2@sha256:b2fdb45228c94a0df0d7d1597009edaa663ff455999ddcf1dc1483d06631762b
    container_name: keila
    restart: unless-stopped
    env_file: ./.env
    environment:
      DB_URL: postgres://keila:${POSTGRES_PASSWORD}@db:5432/keila
      PORT: "4000"
      # Nothing terminates TLS; the browser address is localhost:8145, and the
      # links Keila writes have to say exactly that.
      URL_SCHEMA: http
      URL_PORT: "8145"
      # No sign-up form; .env's root user is the only account.
      DISABLE_REGISTRATION: "true"
      # HOME here is /opt/app; uploads default under it.
      USER_CONTENT_DIR: /opt/app/uploads
      # The digest above is the pin; this check has nothing to act on.
      DISABLE_UPDATE_CHECKS: "true"
    volumes:
      # Root-owned on Linux: the image declares no USER.
      - ./uploads:/opt/app/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8145.
      - "127.0.0.1:8145:4000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  keila-pgdata:
EOF
cd ~/selfhost/keila && 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, because there is no hostname, and no firewall rule, because nothing is published
  beyond loopback.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.

8145 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet.
Mail still leaves, since that connection is outbound: step 1's problem is the links. Confirm
the binding:

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

Assert: that prints `1`, the single published port `- "127.0.0.1:8145:4000"`. PostgreSQL
publishes no host port at all.

## 7. Start and verify

Check first that step 4's relay lines were filled in. A blank one does not stop the release, it
starts a Keila that cannot send. This prints key names, never a value:

```bash
awk -F= '/^MAILER_SMTP_(HOST|USER|PASSWORD|FROM_EMAIL)=/ {print $1 "=" (length($2) ? "set" : "EMPTY")}' ~/selfhost/keila/.env
```

Assert: four lines, all reading `set`. If any reads `EMPTY`, stop and send the user back to step
4. Then start:

```bash
cd ~/selfhost/keila
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8145/auth/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8145/auth/login | grep -o 'Sign in with your email address and password here.'
curl -sS http://localhost:8145/auth/register | grep -o 'Registration disabled.'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8145/api/v1/contacts
```

Assert all four and print what you received for each. The loop ends printing `200`. The first
grep prints `Sign in with your email address and password here.`, the line under the heading on
the first screen. The second prints `Registration disabled.`: the sign-up form is shut, so the
root account is the only one. The last prints `403`, what upstream's API authorization plug
returns to a caller with no bearer token. If any miss, stop, run
`docker compose logs --tail 40 keila` and `--tail 20 db`, and name the cause: a
missing-mailer-variable line is step 4 unfinished, `port is already allocated` means something
else holds 8145. A running container is not success.

STOP: tell the user to read their root password with `grep KEILA_PASSWORD ~/selfhost/keila/.env`,
put it in their password manager, log in at http://localhost:8145/auth/login as `root@localhost`,
and wait. Do not continue until they confirm they are inside. The next screen asks for a project,
and a sender must be added inside it before a campaign can go out.

## 8. First backup and restore

Two artifacts: a database dump with the contacts, their consent, campaigns and click history,
and an archive with the config and the uploads.

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

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. The archive contains .env, with the relay password
and the key base, so treat it like a password-manager export.

Both 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 off this computer, a sync folder or a USB
stick, and copy both there with `cp`. Assert: the user confirms both are listed there.

To restore, in this order. `cd ~/selfhost/keila`, 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, then `docker compose up -d db`, wait 30 seconds for healthy, pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T db psql -U keila -d keila`, then `docker compose up -d` and re-run step
7's asserts. Who opted in and when is the part that cannot be recreated.

## 9. Updating later

New versions are at https://github.com/pentacent/keila/releases. Back up first, then edit the
image line in ~/selfhost/keila/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/keila
docker compose pull
docker compose up -d
docker compose logs --tail 30 keila
```

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

## 10. What will probably go wrong

I scheduled a campaign for the morning, closed the lid, and found it unsent at lunchtime.
Nothing was broken. Keila moves a queue only while this machine is awake
and the Docker daemon is up, and `restart: unless-stopped` acts only once that daemon runs, so a
reboot leaves nothing on 8145 until Docker Desktop starts. Turn on its start-at-login setting,
and run `cd ~/selfhost/keila && docker compose up -d` after a reboot before concluding anything
is wrong.

## 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 send a campaign to anybody but the user, and do not import a contact list. The
  unsubscribe link resolves only on this computer.
- Do not configure hCaptcha or Friendly Captcha keys. They guard a sign-up form this install
  has switched off.
````

## docker-compose.yml

```yaml
# Keila · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ....... https://www.keila.io/docs/installation
#   configuration ...... https://www.keila.io/docs/configuration
#   root user seed ..... https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Two services: Keila and the PostgreSQL it keeps contacts, campaigns and forms
# in. Upstream states PostgreSQL is the only dependency besides a container
# engine, and the release migrates and seeds itself on the way up. The SMTP relay
# is not optional: upstream reads the relay host, from-address and password at
# boot and halts when one is missing. Digests read on 2026-08-06. The Keila image
# publishes linux/amd64 only; PostgreSQL publishes both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

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

  keila:
    image: pentacent/keila:0.30.2@sha256:b2fdb45228c94a0df0d7d1597009edaa663ff455999ddcf1dc1483d06631762b
    container_name: keila
    restart: unless-stopped
    env_file: /srv/keila/.env
    environment:
      # Assembled from .env, so the two cannot disagree.
      DB_URL: postgres://keila:${POSTGRES_PASSWORD}@db:5432/keila
      # The release listens on 4000; the host port below is the only way in.
      PORT: "4000"
      # Caddy terminates TLS, so Keila has to be told the links it writes into
      # campaigns and forms are https. Upstream then defaults URL_PORT to 443.
      URL_SCHEMA: https
      # /auth/register is an open sign-up form on a public hostname otherwise.
      DISABLE_REGISTRATION: "true"
      # HOME here is /opt/app, and uploads default to a path under it.
      USER_CONTENT_DIR: /opt/app/uploads
      # The digest above is the pin; a check from inside cannot act on it.
      DISABLE_UPDATE_CHECKS: "true"
    volumes:
      - /srv/keila/uploads:/opt/app/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8145.
      - "127.0.0.1:8145:4000"
    depends_on:
      db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Keila · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://www.keila.io/docs/installation
#   configuration ...... https://www.keila.io/docs/configuration
#   root user seed ..... https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Two services, every path relative to ~/selfhost/keila/. The database is a
# named volume, not a bind mount: the PostgreSQL image chowns its data
# directory to its own uid, which a home-directory bind mount cannot allow on
# Windows. The SMTP relay is not optional: upstream reads the relay host,
# from-address and password at boot and halts when one is missing. Digests read
# 2026-08-06; Keila is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

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

  keila:
    image: pentacent/keila:0.30.2@sha256:b2fdb45228c94a0df0d7d1597009edaa663ff455999ddcf1dc1483d06631762b
    container_name: keila
    restart: unless-stopped
    env_file: ./.env
    environment:
      DB_URL: postgres://keila:${POSTGRES_PASSWORD}@db:5432/keila
      PORT: "4000"
      # Nothing terminates TLS; the browser address is localhost:8145, and the
      # links Keila writes have to say exactly that.
      URL_SCHEMA: http
      URL_PORT: "8145"
      # No sign-up form; .env's root user is the only account.
      DISABLE_REGISTRATION: "true"
      # HOME here is /opt/app; uploads default under it.
      USER_CONTENT_DIR: /opt/app/uploads
      # The digest above is the pin; this check has nothing to act on.
      DISABLE_UPDATE_CHECKS: "true"
    volumes:
      # Root-owned on Linux: the image declares no USER.
      - ./uploads:/opt/app/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8145.
      - "127.0.0.1:8145:4000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  keila-pgdata:
```

## Caddyfile

```text
# Keila · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.keila.io/docs/installation 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
# URL_HOST in .env: Keila builds unsubscribe links, form URLs and opt-in
# confirmation links from that setting, not from the request, so the two have to
# agree. Upstream asks that a reverse proxy forward WebSockets, which Caddy's
# reverse_proxy does with no extra configuration.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN, the value Keila's own browser pipeline already sets
		# through Phoenix's secure-headers plug. Stated here so it does not
		# depend on that plug staying in the pipeline.
		X-Frame-Options "SAMEORIGIN"
		# An unsubscribe or confirmation URL carries a recipient token in
		# the path, and a full Referer would hand it to the next site.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8145 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8145
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Keila · 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=news.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.keila.io/docs/installation
#   https://www.keila.io/docs/configuration
#   https://www.keila.io/docs/setup
#   https://github.com/pentacent/keila/blob/v0.30.2/priv/repo/seeds.exs
#
# Three secrets are generated here, on this machine: the PostgreSQL password, the
# Phoenix secret key base, and the root account's password. All three go into
# /srv/keila/.env with mode 600 and none of them is ever printed.
#
# This script runs in two passes on a fresh box. The first pass writes .env with
# the relay lines blank and stops, because Keila will not start without an SMTP
# relay and those four values are yours, not this script's. Fill them in, then
# run it again.
#
# DOMAIN_HOST is also URL_HOST, the hostname every unsubscribe link and opt-in
# confirmation link is built from. Choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/keila}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

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. news.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the root account will log in with"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this box is ${arch}; the Keila image is published for linux/amd64 only"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Keila plus PostgreSQL wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 755 "$APP_DIR/uploads"
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 the database password and the key base, which upstream requires to be
# at least 64 characters; base64 for the root password, which a human types into
# a form. Read them later with
#   sudo grep -E 'SECRET_KEY_BASE|KEILA_PASSWORD' /srv/keila/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		SECRET_KEY_BASE=$(openssl rand -hex 48)
		URL_HOST=${DOMAIN_HOST}
		KEILA_USER=${ADMIN_EMAIL}
		KEILA_PASSWORD=$(openssl rand -base64 30)
		MAILER_SMTP_PORT=587
		MAILER_ENABLE_STARTTLS=true
		MAILER_SMTP_HOST=
		MAILER_SMTP_USER=
		MAILER_SMTP_FROM_EMAIL=
		MAILER_SMTP_PASSWORD=
		# the four blank lines above are the relay account, filled in by hand
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
	cat >&2 <<-RELAY

		Wrote $APP_DIR/.env with three generated secrets. Nothing was printed.

		Keila cannot work without an SMTP relay. Edit that file now
		(sudo nano $APP_DIR/.env) and fill in the relay hostname, the account
		name, the relay password and a sending address on a domain you control.
		Port 587 with STARTTLS is set already; a relay documenting 465 wants
		that port, MAILER_ENABLE_STARTTLS=false and MAILER_ENABLE_SSL=true.

		Then run this script again.

	RELAY
	exit 1
fi

# Every relay value must have a value: a blank one starts a Keila that cannot
# send, and an absent one halts the release at boot.
missing="$(sudo awk -F= '/^MAILER_SMTP_(HOST|USER|PASSWORD|FROM_EMAIL)=/ && length($2) == 0 {print $1}' "$APP_DIR/.env" | tr '\n' ' ')"
[ -z "$missing" ] || die "still blank in $APP_DIR/.env: ${missing}"

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-keila"
	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 8145 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; 8145 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 release migrates the database and seeds the root account on the way up.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/auth/login" | grep -q 'Sign in with your email address and password here.' \
	|| die "the login page did not carry its first-screen line. Check: docker compose logs --tail 40 keila"

# The sign-up form must be shut: this hostname is public.
curl -sS "https://${DOMAIN_HOST}/auth/register" | grep -q 'Registration disabled.' \
	|| die "/auth/register is still open. DISABLE_REGISTRATION did not take effect."

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

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

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

cat <<-DONE

	Keila is answering at https://${DOMAIN_HOST}/auth/login

	  1. Your root account is ${ADMIN_EMAIL}. Its password is in $APP_DIR/.env,
	     mode 600. Read it with
	       sudo grep KEILA_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. Sign-up is closed: /auth/register answers with a registration-disabled
	     page, so this public hostname hands accounts to nobody.
	  3. Mail is two settings, not one. The relay in .env carries system mail.
	     Campaigns go out as a sender you add inside a project in the web
	     interface. Send one campaign to yourself before importing anybody.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. Both are on the same disk as the data, which is not a backup,
	     and the archive holds .env, so treat it like a password-manager export.
	     Copy them somewhere else tonight.

DONE
```

## Also evaluated

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

- **listmonk** — Your mailing list, your campaigns and your click stats out of one Go binary, with the sending handed to an SMTP relay you pick. The heavier-throughput sibling, already in this catalogue as the Mailchimp answer, and the better pick once the list is large enough that send rate and queue behaviour are the thing you think about. One Go binary, one PostgreSQL, a deeper API and a bigger pool of people who have already hit whatever breaks at a hundred thousand subscribers. It ranks second here because the daily surface is a database-shaped admin panel rather than a creator's editor: templating is Go templates and the form builder is thinner, which is a fair trade for throughput and a bad trade for someone who came from Kit's canvas.

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