Deployment Guide
Bilko — Deployment Guide
Status: PLANNED (not deployed yet)
This document describes the target deployment architecture for Bilko. Infrastructure has not yet been provisioned.
Overview
Bilko uses a multi-service architecture deployed to different platforms optimized for each component:
Target Domains:
Primary:bilko.io (all regions)Redirect:bilko.rs (Serbia market, 301 redirect to bilko.io)
Deployment Architecture Overview
graph TD
User["User Browser / Mobile PWA"]
DNS["Cloudflare DNS\nbilko.io / bilko.rs"]
CF_PROXY["Cloudflare Proxy\nDDoS + SSL Termination"]
Vercel["Vercel Edge Network\nNext.js 15 Frontend\nbilko.io"]
Railway["Railway EU West\nExpress API\napi.bilko.io"]
PG["PostgreSQL 15\nRailway Managed DB"]
R2["Cloudflare R2\nbilko-receipts bucket\n(Receipts + PDFs)"]
SG["SendGrid\nTransactional Email\[email protected]"]
User --> DNS
DNS --> CF_PROXY
CF_PROXY --> Vercel
CF_PROXY --> Railway
Railway --> PG
Railway --> R2
Railway --> SG
Vercel -->|"HTTPS API calls"| Railway
Environment Topology
graph LR
subgraph LOCAL["Local (Developer)"]
L_WEB["localhost:3000\nNext.js dev"]
L_API["localhost:4000\nExpress dev"]
L_DB["localhost:5432\nbilko_dev DB"]
end
subgraph STAGING["Staging"]
S_WEB["bilko-pr-{n}.vercel.app\nVercel Preview"]
S_API["staging Railway env\nbilko-api-staging"]
S_DB["Railway PostgreSQL\nbilko_staging DB"]
end
subgraph PROD["Production"]
P_WEB["bilko.io\nVercel Production"]
P_API["api.bilko.io\nRailway Production"]
P_DB["Railway PostgreSQL\nbilko_prod DB"]
P_R2["Cloudflare R2\nbilko-receipts"]
end
L_WEB --> L_API --> L_DB
S_WEB --> S_API --> S_DB
P_WEB --> P_API --> P_DB
P_API --> P_R2
Frontend Deployment (Vercel)
Platform: Vercel
Why:Zero-config Next.js deployment, edge CDN, automatic HTTPS, preview deploymentsPricing:Free tier for MVP (handles 10K+ users)Region:Global Edge Network (CDN)
Deployment Process
Initial Setup
Install Vercel CLI:npm install -g vercelLogin:vercel loginLink project:cd apps/web vercel linkDeploy to production:vercel --prod
Environment Variables (Vercel Dashboard)
Required environment variables for production:
NEXT_PUBLIC_API_URL=https://api.bilko.io
NEXT_PUBLIC_APP_ENV=production
Custom Domain Setup
Go to Vercel Dashboard → Project Settings → DomainsAdd custom domain:bilko.ioConfigure DNS (see DNS section below)Vercel auto-provisions SSL certificate
Preview Deployments
Every PR automatically deploys to unique URL:bilko-pr-{number}.vercel.appAllows testing before merging to mainAutomatically deleted after PR merge/close
Backend Deployment (Railway)
Platform: Railway
Why:PostgreSQL included, simple deployment, EU region available (GDPR), affordablePricing:~€20/mo for MVP (starter tier: 2GB RAM, 2 vCPU, 10GB storage)Region:EU West (Frankfurt or Paris — GDPR compliant)
Deployment Process
Initial Setup
Install Railway CLI:npm install -g @railway/cliLogin:railway loginCreate project:railway initProvision PostgreSQL:railway add --database postgresqlDeploy API:cd apps/api railway up
Environment Variables (Railway Dashboard)
Required environment variables for production:
# Database (auto-provisioned by Railway)
DATABASE_URL=${{Postgres.DATABASE_URL}}
# JWT Secrets (generate with: openssl rand -base64 32)
JWT_SECRET=<your-secret-here>
JWT_REFRESH_SECRET=<your-refresh-secret-here>
# SendGrid Email
SENDGRID_API_KEY=<your-sendgrid-key>
# Cloudflare R2 Storage
R2_ACCESS_KEY_ID=<your-r2-key>
R2_SECRET_ACCESS_KEY=<your-r2-secret>
R2_BUCKET_NAME=bilko-receipts
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# App Config
PORT=4000
NODE_ENV=production
ALLOWED_ORIGINS=https://bilko.io,https://www.bilko.io
Database Migrations
Run Prisma migrations on Railway:
railway run npx prisma migrate deploy
Monitoring
Railway provides built-in monitoring:
CPU, memory, network usageLogs:railway logsMetrics: Railway Dashboard → Metrics tab
File Storage (Cloudflare R2)
Platform: Cloudflare R2
Why:Zero egress fees (S3 charges for downloads), S3-compatible APIPricing:~€1/mo for MVP (storage: €0.015/GB, no egress fees)Use Cases:Receipt photos (JPG, PNG), invoice PDFs, expense attachments
Setup Process
1. Create R2 Bucket
Go to Cloudflare Dashboard → R2Create bucket:bilko-receiptsNote Account ID from dashboard
2. Generate API Credentials
R2 → Manage R2 API TokensCreate API token with permissions:Object Read & WriteBucket scope:bilko-receipts
Save Access Key ID and Secret Access Key
3. Configure CORS
Allow uploads from frontend:
{
"AllowedOrigins": ["https://bilko.io", "https://www.bilko.io"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
Apply via Cloudflare Dashboard → R2 → bilko-receipts → Settings → CORS
4. Backend Integration
Use AWS SDK for S3 (R2 is S3-compatible):
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
DNS Configuration (Cloudflare)
DNS + Traffic Routing Flow
flowchart TD
REQ["Incoming Request"]
CHK_RS{{"bilko.rs\nor www.bilko.rs?"}}
REDIRECT["301 Permanent Redirect\nhttps://bilko.io$request.uri"]
CHK_API{{"api.bilko.io?"}}
VERCEL_CNAME["CNAME → cname.vercel-dns.com\n(Proxied via Cloudflare)"]
RAIL_CNAME["CNAME → railway.app\n(DNS only, no proxy)"]
SSL_VERCEL["Vercel SSL\n(Let's Encrypt auto-renew)"]
SSL_RAIL["Railway SSL\n(Let's Encrypt auto-renew)"]
REQ --> CHK_RS
CHK_RS -->|Yes| REDIRECT
CHK_RS -->|No| CHK_API
CHK_API -->|Yes| RAIL_CNAME --> SSL_RAIL
CHK_API -->|No| VERCEL_CNAME --> SSL_VERCEL
Domains
bilko.io— Primary domain (registered, DNS not configured)bilko.rs— Serbia redirect (not yet registered)
DNS Records (Cloudflare Dashboard)
bilko.io
bilko.rs (future)
Redirect Rules (Cloudflare)
Create redirect rule for bilko.rs → bilko.io:
If:Hostname equalsbilko.rsorwww.bilko.rsThen:Dynamic redirect tohttps://bilko.io$request.uri(301 permanent)
Email Configuration (SendGrid)
Platform: SendGrid
Why:Free tier (100 emails/day), email templates, delivery trackingPricing:€0 for MVP (upgrade to €15/mo for 40K emails when needed)
Setup Process
1. Create SendGrid Account
Sign up at sendgrid.comVerify email addressCreate API key with Mail Send permissions
2. Domain Authentication
SendGrid Dashboard → Settings → Sender AuthenticationAuthenticate domain:bilko.ioAdd DNS records to Cloudflare (CNAME for DKIM, TXT for SPF)
3. Email Templates
Create transactional email templates:
Welcome email (user registration)Invoice sent notificationPayment received confirmationPassword reset2FA code
4. Backend Integration
Use SendGrid Node.js SDK:
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
await sgMail.send({
to: "[email protected]",
from: "[email protected]",
subject: "Invoice #12345",
templateId: "d-abc123...",
dynamicTemplateData: { invoiceNumber: "12345" },
});
Cost Estimate
MVP Budget (Monthly)
Scale-up Budget (1K-10K users)
Deployment Checklist
Development Environment
Local PostgreSQL 15+ installedNode.js 18+ installedEnvironment variables in.envfilesDatabase migrated:npx prisma migrate devFrontend runs:npm run dev(port 3000)Backend runs:npm run dev(port 4000)
Staging Environment
Railway project createdPostgreSQL provisioned on RailwayEnvironment variables configuredDatabase migrated:railway run npx prisma migrate deployBackend deployed:railway upVercel project createdFrontend deployed:vercelCustom domain configured:staging.bilko.ioEnd-to-end smoke test passed
Production Environment
DNS configured (bilko.io → Vercel + Railway)SSL certificates provisioned (automatic)Environment variables configured (production secrets)Database migrated:railway run npx prisma migrate deployCloudflare R2 bucket createdCORS configured on R2SendGrid domain authenticatedEmail templates createdBackend deployed:railway up --productionFrontend deployed:vercel --prodMonitoring configured (Railway metrics + Sentry)Backup strategy testedLoad testing completed (1K concurrent users)Incident response plan documented
Rollback Strategy
Rollback Decision Flow
flowchart TD
INC["Incident Detected"]
TYPE{{"Affected\nComponent?"}}
FE_ROLL["Vercel Dashboard\n→ Deployments\n→ Promote to Production\n⏱ Instant (no rebuild)"]
BE_ROLL["Railway Dashboard\n→ Deployments\n→ Redeploy previous\n⏱ ~2 minutes"]
DB_ROLL{{"Data\nCorrupted?"}}
DB_FORWARD["Forward Fix\n(preferred)\nDeploy corrective migration"]
DB_RESTORE["Railway Backup Restore\n→ Select snapshot\n→ Test integrity\n→ Switch traffic"]
INC --> TYPE
TYPE -->|"Frontend only"| FE_ROLL
TYPE -->|"Backend only"| BE_ROLL
TYPE -->|"Database"| DB_ROLL
DB_ROLL -->|No| DB_FORWARD
DB_ROLL -->|Yes| DB_RESTORE
Frontend Rollback (Vercel)
Vercel keeps all previous deployments. To rollback:
Go to Vercel Dashboard → DeploymentsFind previous working deploymentClick "Promote to Production"Instant rollback (no rebuild needed)
Backend Rollback (Railway)
Railway keeps deployment history. To rollback:
Go to Railway Dashboard → DeploymentsSelect previous deploymentClick "Redeploy"Rollback completes in ~2 minutes
Database Rollback
CRITICAL: Database rollbacks are risky. Prefer forward-fixing.
If necessary:
Restore from Railway automated backupRe-run migrations up to previous versionTest data integrity before switching traffic
Prevention:
Always test migrations on staging firstUse backward-compatible changesNever drop columns without multi-step migration plan
Monitoring & Alerts
Railway Monitoring
Built-in metrics:
CPU usageMemory usageNetwork I/ORequest rateError rate
Vercel Analytics
Built-in analytics:
Page viewsCore Web VitalsGeographic distributionDevice breakdown
Uptime Monitoring (Recommended: Uptime Robot)
Free tier monitors:
Frontend (bilko.io)Backend (api.bilko.io/health)Check interval: 5 minutesAlert via email/Slack on downtime
Error Tracking (Recommended: Sentry)
Integrate Sentry for:
JavaScript errors (frontend)API errors (backend)Performance monitoringRelease tracking
Security Considerations
HTTPS Everywhere
Vercel: Automatic HTTPSRailway: Automatic HTTPSCloudflare: Force HTTPS redirect
Secrets Management
NEVER commit secretsto gitStore in platform dashboards (Vercel, Railway)Rotate JWT secrets quarterlyRotate API keys annually
CORS Configuration
Restrict to production domains only:
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS.split(","),
credentials: true,
};
Rate Limiting
Protect API endpoints:
General: 100 req/min per IPAuth: 5 req/min per IPReports: 10 req/min per user
Backup & Disaster Recovery
Database Backups (Railway)
Automated:Daily backups, 30-day retentionManual:Export viapg_dumpbefore major migrationsRestore:Railway Dashboard → Database → Backups → Restore
File Backups (Cloudflare R2)
Strategy:Receipts/PDFs linked in database, R2 is source of truthRetention:Indefinite (regulatory requirement for financial records)Replication:Enable R2 multi-region replication (future)
Recovery Time Objective (RTO)
Target:1 hour (from incident to service restored)Components:Database restore: 15 minutesBackend redeploy: 5 minutesFrontend redeploy: 2 minutesDNS propagation: 5 minutes (cached)
Recovery Point Objective (RPO)
Target:24 hours (maximum data loss acceptable)Achieved via:Daily database backups
Related Documents
CI/CD Pipeline:CI-CD.mdEnvironment Setup:ENVIRONMENT.mdSecurity Architecture:../security/SECURITY-ARCHITECTURE.mdTesting Guide:../testing/TESTING-GUIDE.md
Last Updated: 2026-02-2004-16
Current Status:State: PLANNEDStable Cloud Run deployment with custom domain provisioning
GCP Project Configuration
- Project ID:
tribal-sign-487920-k0 - Region:
europe-north1(Stockholm) - Services:
bilko-api→ https://bilko-api-dh4m46blja-lz.a.run.app (revision 00037)bilko-web→ https://bilko-web-dh4m46blja-lz.a.run.app
Secret Manager
| Secret Name | Version | Purpose |
|---|---|---|
bilko-cors-origins | v2 | Comma-separated list of allowed CORS origins |
bilko-database-url | latest | Cloud SQL connection string (password reset 2026-04-16) |
bilko-jwt-refresh-secret | latest | JWT refresh token secret |
CORS Parsing: Secret bilko-cors-origins is parsed by comma in apps/api/src/app.ts:61
Environment Variables
bilko-web
NEXT_PUBLIC_API_URL=https://bilko-api-dh4m46blja-lz.a.run.app
bilko-api
CORS_ORIGINS→ pulled frombilko-cors-origins:latestSESSION_COOKIE_SECURE=trueNODE_ENV=productionDATABASE_URL→ pulled frombilko-database-url:latest
Custom Domain Setup
Current Domain
- Host:
bilko-demo.alai.no - Mapped to:
bilko-webCloud Run service - DNS Provider: one.com
- DNS Record:
CNAME bilko-demo.alai.no → ghs.googlehosted.com. - TLS Cert: Let's Encrypt (managed by GCP, auto-renews)
- Provisioning Time: 15-30 minutes after DNS propagation
Domain Verification Constraint
Critical: Only alai.no is verified in GCP Search Console (via [email protected]).
basicconsulting.no is NOT verified. All custom domains MUST use *.alai.no subdomains until basicconsulting.no is verified.
Custom Domain Runbook
Prerequisites
- Domain must be verified in Google Search Console by the GCP account owner
- DNS provider access (one.com for
alai.no, Vercel forbasicconsulting.no) gcloudCLI authenticated:gcloud auth login
Step-by-Step
1. Create Domain Mapping
gcloud beta run domain-mappings create \
--service=bilko-web \
--domain=bilko-demo.alai.no \
--region=europe-north1 \
--project=tribal-sign-487920-k0
2. Configure DNS
Add CNAME record at DNS provider:
Type: CNAME
Host: bilko-demo
Value: ghs.googlehosted.com.
TTL: 3600
3. Wait for Certificate Provisioning
gcloud beta run domain-mappings describe bilko-demo.alai.no \
--region=europe-north1 \
--project=tribal-sign-487920-k0
Look for status.conditions → CertificateProvisioned: True
4. Update CORS Allowed Origins
# Get current value
gcloud secrets versions access latest --secret=bilko-cors-origins
# Add new domain
echo "https://bilko-demo.alai.no,https://bilko-web-dh4m46blja-lz.a.run.app" | \
gcloud secrets versions add bilko-cors-origins --data-file=-
5. Deploy New Revision
gcloud run services update-traffic bilko-api \
--to-latest \
--region=europe-north1 \
--project=tribal-sign-487920-k0
6. Verify CORS Preflight
curl -sSI -X OPTIONS \
https://bilko-api-dh4m46blja-lz.a.run.app/api/v1/auth/login \
-H "Origin: https://bilko-demo.alai.no" \
-H "Access-Control-Request-Method: POST"
Expected: HTTP 204 with Access-Control-Allow-Origin: https://bilko-demo.alai.no
GitHub Actions CI/CD
- Workflow:
.github/workflows/deploy-production.yml - Auth: Workload Identity Federation (WIF)
- Service Account:
[email protected] - IAM Roles:
roles/run.admin,roles/iam.serviceAccountUser
Deployment Steps
- Authenticate via WIF
- Build Docker images (api + web)
- Push to Google Container Registry
- Deploy to Cloud Run (europe-north1)
- Run smoke tests (Playwright E2E)
Testing
Backend Tests
- Framework: Vitest
- Location:
apps/api/src/**/*.test.ts - Command:
pnpm test(fromapps/api/)
End-to-End Tests
- Framework: Playwright
- Location:
apps/e2e/tests/ - Command:
pnpm test(fromapps/e2e/) - Runs in CI: Yes (on every deploy)
Recent Fixes (2026-04-16)
Commits
a62b7f6—NoCloudinfrastructureRundeployedserviceyetnames align:bilko-staging-*→bilko-*9b1ced1— Backend: addedcurrencyfield to invoice list, added/api/v1/settings/profileendpoint73693d4— Frontend: avatar initials fallback, invoice step indicator, chat widget ARIA labels
Key Changes
- Service Naming: Production services now named
bilko-apiandbilko-web(no-stagingsuffix) - API Enhancements: Invoice list now includes currency, new profile settings endpoint
- Frontend Fixes: Accessibility improvements (ARIA), avatar initials when no image, visual polish
Rollback Procedure
Rollback to Previous Revision
# List revisions
gcloud run revisions list --service=bilko-api --region=europe-north1
# Rollback
gcloud run services update-traffic bilko-api \
--to-revisions=bilko-api-00036=100 \
--region=europe-north1
Rollback via GitHub Actions
git revert HEAD
git push origin main # Triggers deploy workflow
Troubleshooting
Issue: CORS errors in browser
Cause: Custom domain not in bilko-cors-origins secret
Next Steps:Fix: ProvisionUpdate Railwaysecret project(see step 4 in Custom Domain Runbook), deploy new revision
Issue: 502 Bad Gateway
Cause: Service unhealthy or startup timeout
Fix: Check Cloud Run logs: gcloud run services logs read bilko-api --region=europe-north1 --limit=50
Issue: Database connection timeout
Cause: Cloud SQL Proxy misconfiguration or secret outdated
Fix: Verify bilko-database-url secret, check Cloud SQL instance status
Issue: Custom domain SSL pending
Cause: DNS not propagated or domain not verified in Search Console
Fix: Wait 15-30 min after DNS change, verify domain ownership in Search Console
Architecture Diagram
┌─────────────────┐
│ one.com DNS │
│ bilko-demo. │
│ alai.no │
└────────┬────────┘
│ CNAME
▼
┌─────────────────────────────┐
│ ghs.googlehosted.com │
│ (Google Cloud Load Balancer)│
└────────┬────────────────────┘
│
▼
┌─────────────────────────────┐ ┌──────────────────┐
│ bilko-web (Cloud Run) │──────▶│ bilko-api │
│ Next.js 15 + PostgreSQL,React deploy19 staging│ environmentHTTP │ Express + TS │
│ europe-north1 │ │ europe-north1 │
└─────────────────────────────┘ └────────┬─────────┘
│
▼
┌─────────────────┐
│ Cloud SQL │
│ PostgreSQL 16 │
│ europe-north1 │
└─────────────────┘