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) | Serves | Where |
|---|---|---|
api.example.com | API (apps/api) + PostgreSQL 16 | VPS, Docker Compose, behind an https reverse proxy |
play.example.com | Mini App (apps/miniapp/dist) | Any static host |
admin.example.com | Admin (apps/admin/dist), including client report pages /r/<token> | Any static host with a fallback to index.html |
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
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.
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.
| Host | How |
|---|---|
| Cloudflare Pages | Direct 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. |
| Netlify | Drag 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 / Caddy | Serve 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.mjswritespublic/tonconnect-manifest.jsonon every dev/build fromVITE_PUBLIC_URL(plus optionalVITE_APP_NAME,VITE_APP_ICON_URL,VITE_TERMS_URL,VITE_PRIVACY_URL). WithoutVITE_PUBLIC_URLit warns and points athttp://localhost:5173, and wallets fail to connect. - Admin:
apps/admin/vite.config.tsemitstonconnect-manifest.jsonfromVITE_ADMIN_PUBLIC_URL, named "Campaign Kit Admin" with the iconicon-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
- Create the owner immediatelyOpen
https://admin.example.comright after the first deploy and complete Set up your admin. Until an admin exists, anyone who reaches the admin can create the first owner. - Check the wiringSettings → Workspace shows the API URL and status,
TON_NETWORK, whether a toncenter key is set, whetherPUBLIC_API_URLis https and the admin's TON Connect manifest URL. Secrets are never shown. - 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.
- 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.