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_KEYorPB_SUPERUSER_PASSWORDis missing;WOTP_MOCK_DELIVERYis 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_SECRETis 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 /healthis liveness and always returns 200.GET /health/readyis 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.pyprunes 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-runthe first time.- Alert on a failure-rate spike in the
messagescollection. 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
- WhatsApp setup or Telegram setup, depending on your channel.
- Quickstart: the integration itself.
- FAQ: what it does not do, answered plainly.