Deploy: Fly.io + Neon + Vercel¶
A best-of-breed split: the API on Fly.io (global containers), PostgreSQL on Neon (serverless), and the app + landing on Vercel (static, edge CDN). Pick this when you want each piece on the platform that does it best.
Outcome: API at api.yourdomain.com, app at app.yourdomain.com, landing at yourdomain.com.
1. Database: Neon¶
- Create a project at Neon and copy the connection string (use the pooled one for serverless).
- You'll set it as
FS_DB_URLon the API in the next step.
2. API: Fly.io¶
- Install flyctl, then run
fly launchinbackend/(it detects the Dockerfile, but don't deploy yet). -
Set secrets (Configuration):
-
fly deploy, then run migrations against Neon:./sqitch.sh prod deploy(prod target =FS_DB_URL). - Add the domain:
fly certs add api.yourdomain.com, then point DNS at Fly.
3. App + landing: Vercel¶
Create two Vercel projects from the same repo:
- frontend: root directory
frontend/, buildnpm run build, envVITE_API_BASE_URL=https://api.yourdomain.com; domainapp.yourdomain.com. - landing: root directory
landing/, buildnpm run build; domainyourdomain.com.
Vercel auto-deploys on push and provisions SSL. No other configuration is needed: each directory ships a vercel.json that pins the output directory (build/client) and handles routing (the SPA fallback for the app, clean URLs for the landing). Environment variables set in the Vercel project are available at build time, which is when a static build needs them.
Serving the app from a sub-path¶
To serve the app at yourdomain.com/app instead of app.yourdomain.com: Vercel serves one project per domain, so the landing project (which owns yourdomain.com) forwards the prefix. In the landing's vercel.json, add a rewrite that preserves the path:
{
"rewrites": [{ "source": "/app/:path*", "destination": "https://<frontend-deployment>/app/:path*" }]
}
The frontend and backend settings that go with this are in Serving from a Sub-Path.
4. Wire it together¶
- DNS:
api→ Fly.io;appand the apex → Vercel. - Confirm
FS_BASE_API_URL/FS_BASE_WEB_URLmatch the live URLs so CORS and cookies work (Configuration). - Stripe webhook →
https://api.yourdomain.com/webhooks/stripe(Billing & Subscriptions). - Review the Security checklist before launch.
Next steps¶
Security headers¶
The kit ships frontend/vercel.json (it pins the output directory and provides the SPA fallback). Add a headers key to it:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; connect-src 'self' https://api.yourdomain.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
},
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}
Replace https://api.yourdomain.com in connect-src with your real API URL, or the browser will block the app from calling it. See Security.