Campaign Kit docs
v0.1.0
Get help
● Deploy · Production

Production deployment
API on a VPS, apps on static hosting.

Telegram only loads Mini Apps and delivers webhooks over https. This page puts the API and PostgreSQL on a VPS with Docker Compose behind a TLS reverse proxy, and the Mini App and admin on static hosting.

01The layout

Hostname (example)ServesWhere
api.example.comAPI (apps/api) + PostgreSQL 16VPS, Docker Compose, behind an https reverse proxy
play.example.comMini App (apps/miniapp/dist)Any static host
admin.example.comAdmin (apps/admin/dist), including client report pages /r/<token>Any static host with a fallback to index.html
Same site for admin and API

The admin session is an httpOnly cookie with SameSite=Lax (apps/api/src/lib/admin-auth.ts), marked Secure in production. Browsers only send it between hosts of the same registrable domain. Put the admin and the API under one domain (admin.example.com + api.example.com). A default host subdomain for the admin (for example *.pages.dev or *.netlify.app) with the API on your own domain makes login fail: attach your custom domain to the static host.

021. API + PostgreSQL with Docker Compose

Install Docker Engine with the Compose plugin on the server (Docker's official guide for your Linux distribution). Copy the repository to the server and create the configuration:

cp .env.example .env

Fill at least these values (every variable is explained on Configuration):

ENCRYPTION_KEY=<openssl rand -hex 32>
ADMIN_JWT_SECRET=<openssl rand -hex 32>
ABUSE_HASH_SALT=<openssl rand -hex 16>
POSTGRES_PASSWORD=<openssl rand -hex 24>
PUBLIC_API_URL=https://api.example.com
CORS_ORIGINS=https://play.example.com,https://admin.example.com
ADMIN_PUBLIC_URL=https://admin.example.com
TON_NETWORK=testnet
BROADCAST_WORKER=true

Do not add NODE_ENV, and you do not need to change DATABASE_URL, PORT or TRUST_PROXY: docker-compose.yml sets them for the container (NODE_ENV=production, the internal database URL, TRUST_PROXY=true). Every other variable in .env reaches the API through env_file.

Start PostgreSQL 16 and the API:

docker compose up -d --build

The API image runs the database migrations on every start, then serves on port 8787. Compose refuses to start when POSTGRES_PASSWORD is empty. The API port is published on 127.0.0.1:8787 only, so the internet reaches it only through your proxy. Check it:

docker compose ps
curl http://127.0.0.1:8787/health
docker compose logs -f api
One API replica

Rate limits are kept in memory per API process (per IP: burst 120, then 20/s; per player: burst 40, then 8/s; admin login: 10 tries, then 1 per 6 s), and the broadcast worker must run in exactly one process. Run a single API container. If you ever scale out, set BROADCAST_WORKER=false on every replica but one and add a shared rate limiter at the proxy.

Not verified on this machine

Docker was not available where this release's documentation was checked, so the Compose commands were verified by reading docker-compose.yml and apps/api/Dockerfile, not by running them. The same API build (pnpm --filter @kit/api build, migrations, /health) was run directly.

032. https reverse proxy

Point a DNS A record for api.example.com at the VPS first. Then pick one option.

Option A: Caddy (automatic certificates)

Install Caddy on the host, put this in /etc/caddy/Caddyfile and run sudo systemctl reload caddy:

api.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:8787
}

Caddy obtains and renews the certificate and sends X-Forwarded-For. If you also host the static apps on the same VPS:

play.example.com {
    root * /var/www/miniapp
    file_server
}

admin.example.com {
    root * /var/www/admin
    try_files {path} /index.html
    file_server
}

Option B: nginx + certbot

server {
    listen 80;
    server_name api.example.com;
    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then sudo certbot --nginx -d api.example.com adds the certificate and the https redirect. Setting X-Forwarded-For to $remote_addr (instead of appending) stops clients from injecting their own value; the API trusts this header because TRUST_PROXY=true.

When https://api.example.com/health answers, make sure PUBLIC_API_URL=https://api.example.com, run docker compose up -d again, and save the bot token again in the admin so the webhook is registered.

043. Build and host the Mini App and admin

Build on your own machine or in CI, with production values in the root .env. VITE_* values are baked in at build time: rebuild whenever you change one.

VITE_API_URL=https://api.example.com
VITE_PUBLIC_URL=https://play.example.com
VITE_ADMIN_API_URL=https://api.example.com
VITE_ADMIN_PUBLIC_URL=https://admin.example.com
pnpm --filter @kit/miniapp build
pnpm --filter @kit/admin build

Upload the contents of apps/miniapp/dist to the Mini App host and apps/admin/dist to the admin host.

HostHow
Cloudflare PagesDirect Upload of the dist folder, then add your custom domain. Without a 404.html in the upload, Pages serves index.html for unknown paths, which the admin's routes need.
NetlifyDrag and drop the dist folder (or the CLI) and add your custom domain. For the admin, add a _redirects file with /* /index.html 200 so deep links such as /c/…/airdrop and /r/<token> survive a reload.
nginx / CaddyServe the files over https. For the admin, fall back to /index.html for unknown paths (try_files $uri /index.html; in nginx).

The production builds include a Content-Security-Policy meta tag that allows API calls only to your VITE_API_URL / VITE_ADMIN_API_URL origin (and the Adsgram script for the Mini App).

054. TON Connect manifests

Wallets download a small tonconnect-manifest.json to show who is asking to connect. Both are generated; never edit them by hand.

  • Mini App: apps/miniapp/scripts/gen-manifest.mjs writes public/tonconnect-manifest.json on every dev/build from VITE_PUBLIC_URL (plus optional VITE_APP_NAME, VITE_APP_ICON_URL, VITE_TERMS_URL, VITE_PRIVACY_URL). Without VITE_PUBLIC_URL it warns and points at http://localhost:5173, and wallets fail to connect.
  • Admin: apps/admin/vite.config.ts emits tonconnect-manifest.json from VITE_ADMIN_PUBLIC_URL, named "Campaign Kit Admin" with the icon icon-180.png.

After deploying, open https://play.example.com/tonconnect-manifest.json and https://admin.example.com/tonconnect-manifest.json. Both must load and show your https URL.

065. First login and go-live order

  1. Create the owner immediatelyOpen https://admin.example.com right after the first deploy and complete Set up your admin. Until an admin exists, anyone who reaches the admin can create the first owner.
  2. Check the wiringSettings → Workspace shows the API URL and status, TON_NETWORK, whether a toncenter key is set, whether PUBLIC_API_URL is https and the admin's TON Connect manifest URL. Secrets are never shown.
  3. Create the campaign and connect the botFollow Telegram bot & first campaign. The Launch checklist must show Webhook registered and Chat menu button opens the Mini App.
  4. Test in TelegramOpen your bot, press Start, open the Mini App, tap, complete a quest. Then go live.

076. Backups

All campaign data (players, point ledger, seasons, squads, snapshots, audit log, encrypted bot tokens) is in PostgreSQL. Back it up daily and keep a copy off the server. Back up .env separately and securely: without the same ENCRYPTION_KEY, restored bot tokens cannot be decrypted.

# backup (run from the repo folder on the VPS)
docker compose exec -T db pg_dump -U campaign_kit -Fc campaign_kit > backup-$(date +%F).dump

# restore into the running database (overwrites existing objects)
docker compose exec -T db pg_restore -U campaign_kit -d campaign_kit --clean --if-exists < backup-2026-01-01.dump

Schedule the backup with cron and test a restore on a spare machine before you rely on it.