Build the Billuu infrastructure from scratch
Source:
g2labs-in/linode-infra@968c507·docs/runbooks/build-from-scratch.md· updated 2026-09-14
This guide takes you from an empty cloud account to a running production, on Azure or any other
provider. It repeats nothing about the why of the design. For that, read CONTEXT.md, the plans in
docs/superpowers/plans/, and the ADRs.
Working time: about two hours. Nothing here is destructive to an existing environment. Numbers and IDs in this file are examples from the Azure build; substitute your own.
0. What you need before you start
| Item | Why |
|---|---|
| A domain you can change nameservers on | the whole stack hangs off it |
| A Cloudflare account | DNS, TLS, proxy, WAF, rate limit, Access |
| A Supabase account | managed Postgres with a pooler |
| Access to the GitHub org that holds the backend repo | Dokploy deploys from Git |
| An object storage account with an S3 API | offsite backups. Cloudflare R2 is what we use |
| A healthchecks.io account | tells you when a backup stops running |
| A Tailscale account | private admin access with no public ports |
| A password manager | every secret, none of them in this repo |
Write down, in the password manager, the names of the artifacts you will create: VM IP, Tailscale auth key, Dokploy API key, Cloudflare API token, R2 keys, database URLs, app secret set. Values only, never in git.
1. Decisions to make first
| Decision | Guidance |
|---|---|
| Region | put the app near the database and near the users. Ours: database Mumbai, host Pune (centralindia) |
| VM size | 2 vCPU and 4 GB is the floor. The backend plus Docker plus Traefik fits, with headroom for builds |
| Disk | 64 GB. Dokploy builds images on the host, so leave room |
| Database | managed, never self-hosted, unless you enjoy on-call. Supabase or any Postgres with a pooler |
| Backups | object storage in a different provider from the app host |
| DNS and edge | Cloudflare, from day one. You will want the proxy, the WAF, and Access later |
2. Cost estimate
Prices move. Check before you commit, and get approval before creating anything billable.
| Item | Azure reference | Typical alternatives |
|---|---|---|
| VM, 2 vCPU / 4 GB | Standard_B2ls_v2, about $35/mo | Hetzner CX22 about €4, DigitalOcean 2 GB about $12, Linode 4 GB about $24 |
| Disk, 64 GB SSD | about $5/mo | included on most providers |
| Static IPv4 | about $3.65/mo | free on most providers, small charge on some |
| Managed Postgres | Supabase free tier, or $25 for point in time recovery | Neon, RDS, Cloud SQL |
| DNS, proxy, WAF | Cloudflare Free, $0 | Cloudflare Free |
| Object storage | R2 free tier, about 10 GB | Backblaze B2, S3 |
| Alerting | healthchecks.io free tier | Better Stack, UptimeRobot |
3. Phase 1: create the host
Azure, with the resource group, firewall, and a static IP:
az group create -n billuu-prod -l centralindia
az network nsg create -g billuu-prod -n billuu-nsg -l centralindiaaz network nsg rule create -g billuu-prod --nsg-name billuu-nsg -n allow-ssh \ --priority 100 --protocol Tcp --destination-port-ranges 22 --access Allowaz network nsg rule create -g billuu-prod --nsg-name billuu-nsg -n allow-http \ --priority 110 --protocol Tcp --destination-port-ranges 80 --access Allowaz network nsg rule create -g billuu-prod --nsg-name billuu-nsg -n allow-https \ --priority 120 --protocol Tcp --destination-port-ranges 443 --access Allow
az network public-ip create -g billuu-prod -n billuu-app-ip -l centralindia \ --sku Standard --allocation-method Static
envsubst '${TS_AUTHKEY}' < azure/cloud-init.yaml > /tmp/billuu-ci.yaml
az vm create -g billuu-prod -n billuu-app -l centralindia \ --image Ubuntu2404 --size Standard_B2ls_v2 \ --admin-username codejack --ssh-key-values ~/.ssh/id_ed25519.pub \ --public-ip-address billuu-app-ip --nsg billuu-nsg \ --os-disk-size-gb 64 --storage-sku StandardSSD_LRS \ --custom-data /tmp/billuu-ci.yamlrm -f /tmp/billuu-ci.yamlazure/cloud-init.yaml installs Docker, Tailscale, UFW, and fail2ban, and joins the tailnet. It
expects TS_AUTHKEY from your password manager.
Other providers:
| Provider | Create | Firewall | Static IP |
|---|---|---|---|
| Hetzner | hcloud server create | cloud firewall | assign a primary IP |
| DigitalOcean | doctl compute droplet create | cloud firewall | reserved IP |
| Linode | linode-cli linodes create | cloud firewall | reserved IP |
| GCP | gcloud compute instances create | VPC firewall rules | reserved address |
The rest of this guide assumes Ubuntu 24.04 and works the same everywhere.
Verify:
ssh codejack@<vm-ip> 'cloud-init status --long; docker --version; tailscale status | head -3'4. Phase 2: hardening check
ssh codejack@<tailscale-ip> 'sudo ufw status | head -8; sudo fail2ban-client status sshd'ssh codejack@<tailscale-ip> 'sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"'Expected: 22, 80, 443 open, everything on tailscale0 allowed, fail2ban active, root login no,
password auth no.
Delete the Tailscale auth key now. It is single use and short lived by design.
5. Phase 3: Dokploy
ssh codejack@<tailscale-ip> 'curl -sSL https://dokploy.com/install.sh | sudo sh'Open http://<tailscale-ip>:3000, create the first account, then Settings, API/CLI, Create API Key.
Store it as DOKPLOY_API_KEY, with DOKPLOY_URL alongside.
For command line use, install the CLI where you work, not on the host:
dokploy auth -u "$DOKPLOY_URL" -t "$DOKPLOY_API_KEY"dokploy project all --jsonCreate the project, the environment, and the application. Connect the GitHub org through the browser flow, pick the repository and the branch you deploy from.
6. Phase 4: the database
- Create a Supabase project in the region your host can reach quickly.
- Note two connection strings from the dashboard, both on the pooler host:
session pooler, port 5432 used by pg_dump and long jobstransaction pooler, port 6543 used by the application at runtime- Store both in the password manager. The application uses the transaction pooler, the backup script
uses the session pooler, because
pg_dumpcannot work through a transaction pooler. - Apply the schema. Either restore a dump (see
docs/runbooks/database-restore.md) or let the application create its control plane tables on first boot.
Traps, all of which we hit:
- The direct database hostname
db.<ref>.supabase.comay not resolve. Use the pooler. - Connections require
PGSSLMODE=require. - Supabase free tier has no point in time recovery. Nightly logical dumps are your only safety net.
- A paused free project answers
tenant/user not foundon the pooler. That means paused, not lost.
7. Phase 5: Redis
Add Redis as a Dokploy resource on the same network as the application. Copy the generated password and build the URL:
REDIS_URL=redis://default:<password>@<redis-service>:6379REDIS_ENABLED=trueREDIS_ENABLED=true is not optional. With it unset the application hands a mock client to the queue
workers and the process exits with code 1 during startup.
8. Phase 6: the application
Set the environment before the first deploy. Dokploy replaces the whole set on every save, so keep a copy of what works.
Required groups:
DATABASE_URL, CONTROL_PLANE_DATABASE_URL transaction poolerJWT_SECRET, REFRESH_TOKEN_SECRET, ACCESS_TOKEN_SECRET, ENCRYPTION_KEYREDIS_URL, REDIS_ENABLED=trueNODE_ENV=production, PORT=8000FRONTEND_URL, RENDER_EXTERNAL_URL, GOOGLE_CALLBACK_URLRAZORPAY_*, CLOUDINARY_*, WHATSAPP_*, EMAIL_*, GOOGLE_CLIENT_*DB_SYNC=falseAdd the domain, port 8000, HTTPS with Let’s Encrypt. Then deploy.
Traps:
- Choose a build type.
nixpacksbuilds this repository without extra work. If you use the Dockerfile, confirm it matches the current directory layout, or the build fails on missing paths. - If you set a publish directory, Dokploy also runs a static build and can overwrite the image with nginx. Leave it empty.
- The application refuses to start in production when the schema guard finds models without matching
migrations.
DATA_FIRST_LENIENT=truestarts it with a warning. Fix the migrations when you can. save-environmentrequires--buildArgs "" --buildSecrets "" --createEnvFilewith the CLI.
Verify before touching DNS:
ssh codejack@<vm-ip> 'CID=$(docker ps --filter name=<service> -q | head -1); \ docker exec "$CID" node -e "require(\"http\").get(\"http://127.0.0.1:8000/health\",r=>r.pipe(process.stdout))"'Expect {"success":true,...,"db":"ok","redis":"ok"}.
9. Phase 7: DNS and Cloudflare
- Add the Zone in Cloudflare, free plan. Cloudflare scans existing records.
- Compare the scan against the true list. Missing records cause outages, not warnings. Ours were:
A testing, dokploy, main -> the host's static IPCNAME @ and www -> the Netlify siteCNAME billing -> the billing portalCNAME order -> the customer appMX apex, five records -> the mail providerTXT apex, SPF -> the mail provider- Keep the Netlify records DNS only (grey cloud). Netlify manages their own certificates.
- Change nameservers at the registrar to the pair Cloudflare assigns.
- Wait for the zone to become Active. Verify before and after:
curl -H 'accept: application/dns-json' \ "https://cloudflare-dns.com/dns-query?name=testing.example.com&type=A"- Set SSL/TLS mode to Full (strict). The origin already has a real certificate.
- Flip the API and panel records to Proxied, one at a time, testing after each.
Trap: once a hostname is proxied, any Cloudflare Access application on it answers the ACME HTTP
challenge, so Let’s Encrypt renewal fails about 60 days later. Either install a Cloudflare Origin
Certificate on the origin, or add an Access bypass policy for /.well-known/acme-challenge/*.
10. Phase 8: backups and alerting
Four scripts and five cron entries. Copy them from host/ in this repo, do not retype them.
billuu-db-backup.sh nightly production dump -> r2:.../db/billuu-dokploy-backup.sh nightly Dokploy dump -> r2:.../dokploy/billuu-volume-backup.sh host config and Redis -> r2:.../volumes/billuu-db-verify.sh weekly restore proofbilluu-cf-refresh.sh refresh the Cloudflare edge IP listSteps:
- Create a bucket, for example
billuu-pg-backup. - Create an R2 API token scoped to that bucket, permission Object Read & Write. Store both keys.
- Write
/etc/billuu/backup.env, mode 600, root owned. It holds the database URL, the R2 keys, and the alert URLs. - Create two healthchecks.io checks: one daily with a two hour grace, one weekly with a six hour
grace. Put the ping URLs in
HC_URLandHC_VERIFY_URL. - Install the scripts in
/usr/local/bin, the cron files in/etc/cron.d, and logrotate entries. - Run each script once and read its log.
Trap: a bucket-scoped R2 token cannot HEAD or create buckets. Every rclone call needs
--s3-no-check-bucket, or the upload fails with AccessDenied on CreateBucket.
Trap: the postgres image starts the server twice on first boot. A readiness check must confirm two
successful queries with a gap, or a restore hits the temporary server.
11. Phase 9: protections
Panel gate. Cloudflare Zero Trust, one time setup: pick a team name, pick the free plan. Then add an Access application for the panel hostname, with an allow policy listing emails, and One-time PIN as the login method.
Origin firewall. Once the API and panel are proxied, restrict port 443 to Cloudflare edge IPs plus
Tailscale. Docker publishes ports through docker-proxy, so the rule must exist in the DOCKER-USER
chain as well as UFW. billuu-firewall.sh in this repo does both, and billuu-cf-refresh.sh keeps the
IP list current. Keep port 80 open: Let’s Encrypt validates from its own addresses.
Rate limiting. One Cloudflare rule on /api/auth/*, 40 requests per 10 seconds per IP, block for
10 seconds. On the free plan the period and the mitigation timeout are both fixed at 10 seconds, and
cf.colo.id is required in the characteristics.
Client IPs. After proxying, Traefik must trust the Cloudflare ranges, or logs and audit entries show edge IPs instead of visitors.
12. Phase 10: verification checklist
Run all of these. Each has an expected result.
[ ] ssh over Tailscale works, public SSH still works on 22[ ] https://<api host>/health returns 200 with db ok and redis ok[ ] a real login works from the frontend[ ] an order can be created end to end[ ] https://<panel host> shows the Access gate, then the Dokploy login[ ] direct connection to the VM IP on 443 is refused or times out[ ] all five cron entries exist and one backup ran with a green healthchecks check[ ] the weekly verify script prints table and row counts in its log[ ] a scratch restore of the newest dump succeeds[ ] SSL mode is Full (strict) and the certificate chain validates13. Rebuilding an existing environment instead of building fresh
If the old environment is gone but the backups survive, this path is faster:
- Create the host and install Dokploy, as above.
- Restore the Dokploy dump into Dokploy’s own Postgres, or paste the environment set from the password manager.
- Restore the production dump into the database (new Supabase project or scratch instance).
- Point the application at it and redeploy.
- Repoint DNS.
The Dokploy dump carries the deploy settings and the environment variables. That is why we back it up.
14. Traps, in one list
REDIS_ENABLED unset app exits 1 during startup, workers get a mock clientDATA_FIRST_LENIENT unset production startup blocked by the migration guardpublishDirectory set to "." Dokploy runs a static nginx build and overwrites the imagestale Dockerfile paths build fails on COPY of directories that no longer existsave-environment replaces the whole set, not a mergepg_dump on the transaction pooler fails or hangs. Use the session pooler, port 5432rclone without --s3-no-check-bucket AccessDenied on CreateBucketpostgres image first boot temporary server, readiness checks must be patientAccess on a proxied host Let's Encrypt renewal fails. Use an Origin Certificateproxying Netlify records breaks their certificates. Keep them DNS onlyNSG or UFW closed on 80 certificate issuance failsCloudflare edge IPs change refresh the list daily, or the origin starts rejecting trafficDOCKER-USER hook without -i eth0 the rule also matches container egress, so every container loses HTTPS to the internet. Match inbound only15. Where to look next
CONTEXT.md what is true today: hosts, DNS, firewall, backupsdocs/runbooks/database-restore.md the restore procedure, verifieddocs/adr/ why the decisions were madedocs/superpowers/plans/ the cost and migration plans.scratch/ every open issue, one folder eachHost files live in host/ in the repo: the scripts, the cron entries, the systemd unit, the logrotate
rules, and an example config with empty values. Deploy them with scp then sudo install -m 755,
never by editing on the host alone.