docs · self-hosting

it runs on a four dollar box.

Three containers, no external services, no account anywhere. Docker Compose, a reverse proxy for TLS, and a SQLite file you can back up by copying it. This page is the honest version, including the parts that will bite you.

What you are running

  • api: the FastAPI hot path your applications call.
  • pocketbase: data store and the operator back office. Single SQLite file.
  • web: the dashboard you sign in to.

No Postgres, no Redis, no queue, no object storage. That is a deliberate constraint, not an accident, and it is why the whole stack fits on the smallest VPS you can rent.

First run

git clone https://github.com/Luckyyaduvanshiofficial/wotp.git
cd wotp
cp .env.example .env

docker compose up -d

# There is no public signup. Create the operator account explicitly.
docker compose exec api python scripts/create_admin.py you@example.com

Then open the dashboard at http://localhost:3000, sign in, create an API key, and follow the onboarding checklist.

Every port binds to loopback, on purpose

Out of the box this stack is reachable from the machine it runs on and from nowhere else. That is deliberate. The PocketBase admin UI is the control plane for every credential the installation holds: it can read the encrypted Meta token, every API key hash and every audit row. It must not be one docker compose up away from the public internet.

Reach it over an SSH tunnel when you need it, and put a TLS-terminating reverse proxy in front of the two services a browser actually needs:

ssh -L 8090:127.0.0.1:8090 you@your-server
# then browse to http://localhost:8090/_/

PB_BIND, API_BIND and WEB_BIND override each bind address if you know exactly what you are exposing.

What production refuses to boot without

Set APP_ENV=production and the API stops warning and starts refusing. It will not start when:

  • SECRET_KEY, WOTP_FERNET_KEY or PB_SUPERUSER_PASSWORD is missing;
  • WOTP_MOCK_DELIVERY is on, because that mode fakes delivery, and an install that believes it is live while faking delivery is the most dangerous state this software can be in;
  • WhatsApp credentials are configured but META_APP_SECRET is empty, because without it inbound webhook calls cannot be signature-checked.

These are refusals rather than warnings because every one of them is a silent security downgrade that nobody notices until it matters.

Putting TLS in front

Two hostnames, because the API and the dashboard are different origins and the API’s CORS allowlist is a single exact origin.

otp.example.com {
    reverse_proxy 127.0.0.1:3000
}

api.example.com {
    reverse_proxy 127.0.0.1:8000
}

Then set, in .env:

APP_URL=https://api.example.com
DASHBOARD_ORIGIN=https://otp.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_APP_URL=https://otp.example.com
TRUST_PROXY_HEADERS=1

TRUST_PROXY_HEADERS=1 makes the per-IP rate limiter read the right-mostX-Forwarded-For entry, the one your proxy appends. Only turn it on when a proxy you control is in front. With the API exposed directly, that header is client-supplied and the limit becomes bypassable.

The dashboard reads NEXT_PUBLIC_* at build time, so changing them needs docker compose build web, not just a restart.

Backups

pb_data/ is the whole system of record: API key hashes, the OTP audit log, linked Telegram accounts, your settings and the encrypted provider tokens. Losing it means losing every key and every audit row.

# /etc/cron.daily/wotp-backup
#!/bin/sh
set -eu
STAMP=$(date +%F)
docker run --rm \
  -v wotp_pb_data:/data:ro \
  -v /var/backups/wotp:/backup \
  alpine tar czf "/backup/pb_data-$STAMP.tgz" -C /data .
find /var/backups/wotp -name 'pb_data-*.tgz' -mtime +30 -delete

Do a restore drill before you rely on it. An untested backup is a hypothesis. The full restore procedure is in the repository, and it takes about a minute.

Rotating WOTP_FERNET_KEY makes previously stored provider tokens undecryptable. Back that key up with the data, or be ready to re-enter the Meta token and the bot token afterwards.

Upgrades

git pull
docker compose build
docker compose up -d

PocketBase applies new migrations on start. They are written to be idempotent and guarded, but take a backup first anyway: the migration that carries data forward cannot always carry it back.

Monitoring and retention

  • GET /health is liveness and always returns 200. GET /health/ready is the one that matters: it returns 503 when the store is unreachable, and reports provider configuration state by variable name, never by value.
  • scripts/cleanup.py prunes expired codes and old audit rows. It refuses to delete anything from the current UTC month, because those rows are what the monthly cap counts. Run it from cron with --dry-run the first time.
  • Alert on a failure-rate spike in the messages collection. It is the earliest signal of both a provider policy problem and an abuse attempt.

One process, and the honest reason

Run the API with --workers 1. That is a correctness requirement rather than a tuning knob: the send and verify locks, the idempotency store, the rate limiters and the cached PocketBase token are all process-global. Two workers silently break idempotency, let two concurrent sends pass the same quota check, and halve every rate limit.

For the scale this software is built for, one worker is not the bottleneck. If you outgrow it, the fix is a shared store behind those four things, not more workers.

Next