Skip to main content

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:

ComponentPlatformStatus
Frontend (Next.js 15)VercelPLANNED
Backend (Express + PostgreSQL)RailwayPLANNED
File Storage (Receipts, PDFs)Cloudflare R2PLANNED
Email (Transactional)SendGridPLANNED
DNSCloudflarebilko.io registered, DNS not configured

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 deployments
  • Pricing: Free tier for MVP (handles 10K+ users)
  • Region: Global Edge Network (CDN)

Deployment Process

Initial Setup

  1. Install Vercel CLI:

    npm install -g vercel
    
  2. Login:

    vercel login
    
  3. Link project:

    cd apps/web
    vercel link
    
  4. Deploy 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

  1. Go to Vercel Dashboard → Project Settings → Domains
  2. Add custom domain: bilko.io
  3. Configure DNS (see DNS section below)
  4. Vercel auto-provisions SSL certificate

Preview Deployments

  • Every PR automatically deploys to unique URL: bilko-pr-{number}.vercel.app
  • Allows testing before merging to main
  • Automatically deleted after PR merge/close

Backend Deployment (Railway)

Platform: Railway

  • Why: PostgreSQL included, simple deployment, EU region available (GDPR), affordable
  • Pricing: ~€20/mo for MVP (starter tier: 2GB RAM, 2 vCPU, 10GB storage)
  • Region: EU West (Frankfurt or Paris — GDPR compliant)

Deployment Process

Initial Setup

  1. Install Railway CLI:

    npm install -g @railway/cli
    
  2. Login:

    railway login
    
  3. Create project:

    railway init
    
  4. Provision PostgreSQL:

    railway add --database postgresql
    
  5. Deploy 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 usage
  • Logs: railway logs
  • Metrics: Railway Dashboard → Metrics tab

File Storage (Cloudflare R2)

Platform: Cloudflare R2

  • Why: Zero egress fees (S3 charges for downloads), S3-compatible API
  • Pricing: ~€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

  1. Go to Cloudflare Dashboard → R2
  2. Create bucket: bilko-receipts
  3. Note Account ID from dashboard

2. Generate API Credentials

  1. R2 → Manage R2 API Tokens
  2. Create API token with permissions:
    • Object Read & Write
    • Bucket scope: bilko-receipts
  3. 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

TypeNameValueProxy
CNAME@cname.vercel-dns.comYes
CNAMEwwwcname.vercel-dns.comYes
CNAMEapi.railway.appNo

bilko.rs (future)

TypeNameValueProxy
CNAME@bilko.ioYes (301 redirect rule)

Redirect Rules (Cloudflare)

Create redirect rule for bilko.rs → bilko.io:

  • If: Hostname equals bilko.rs or www.bilko.rs
  • Then: Dynamic redirect to https://bilko.io$request.uri (301 permanent)

Email Configuration (SendGrid)

Platform: SendGrid

  • Why: Free tier (100 emails/day), email templates, delivery tracking
  • Pricing: €0 for MVP (upgrade to €15/mo for 40K emails when needed)

Setup Process

1. Create SendGrid Account

  1. Sign up at sendgrid.com
  2. Verify email address
  3. Create API key with Mail Send permissions

2. Domain Authentication

  1. SendGrid Dashboard → Settings → Sender Authentication
  2. Authenticate domain: bilko.io
  3. Add DNS records to Cloudflare (CNAME for DKIM, TXT for SPF)

3. Email Templates

Create transactional email templates:

  • Welcome email (user registration)
  • Invoice sent notification
  • Payment received confirmation
  • Password reset
  • 2FA 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)

ServiceTierCost
Vercel (Frontend)Hobby€0
Railway (Backend + DB)Starter (2GB RAM)€20
Cloudflare R2 (Storage)Pay-as-you-go€1
SendGrid (Email)Free (100/day)€0
TOTAL€21/mo

Scale-up Budget (1K-10K users)

ServiceTierCost
Vercel (Frontend)Pro€20
Railway (Backend + DB)Pro (8GB RAM)€50
Cloudflare R2 (Storage)Pay-as-you-go€5
SendGrid (Email)Essentials (40K/mo)€15
TOTAL€90/mo

Deployment Checklist

Development Environment

  •  Local PostgreSQL 15+ installed
  •  Node.js 18+ installed
  •  Environment variables in .env files
  •  Database migrated: npx prisma migrate dev
  •  Frontend runs: npm run dev (port 3000)
  •  Backend runs: npm run dev (port 4000)

Staging Environment

  •  Railway project created
  •  PostgreSQL provisioned on Railway
  •  Environment variables configured
  •  Database migrated: railway run npx prisma migrate deploy
  •  Backend deployed: railway up
  •  Vercel project created
  •  Frontend deployed: vercel
  •  Custom domain configured: staging.bilko.io
  •  End-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 deploy
  •  Cloudflare R2 bucket created
  •  CORS configured on R2
  •  SendGrid domain authenticated
  •  Email templates created
  •  Backend deployed: railway up --production
  •  Frontend deployed: vercel --prod
  •  Monitoring configured (Railway metrics + Sentry)
  •  Backup strategy tested
  •  Load 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:

  1. Go to Vercel Dashboard → Deployments
  2. Find previous working deployment
  3. Click "Promote to Production"
  4. Instant rollback (no rebuild needed)

Backend Rollback (Railway)

Railway keeps deployment history. To rollback:

  1. Go to Railway Dashboard → Deployments
  2. Select previous deployment
  3. Click "Redeploy"
  4. Rollback completes in ~2 minutes

Database Rollback

CRITICAL: Database rollbacks are risky. Prefer forward-fixing.

If necessary:

  1. Restore from Railway automated backup
  2. Re-run migrations up to previous version
  3. Test data integrity before switching traffic

Prevention:

  • Always test migrations on staging first
  • Use backward-compatible changes
  • Never drop columns without multi-step migration plan

Monitoring & Alerts

Railway Monitoring

Built-in metrics:

  • CPU usage
  • Memory usage
  • Network I/O
  • Request rate
  • Error rate

Vercel Analytics

Built-in analytics:

  • Page views
  • Core Web Vitals
  • Geographic distribution
  • Device breakdown

Uptime Monitoring (Recommended: Uptime Robot)

Free tier monitors:

  • Frontend (bilko.io)
  • Backend (api.bilko.io/health)
  • Check interval: 5 minutes
  • Alert via email/Slack on downtime

Error Tracking (Recommended: Sentry)

Integrate Sentry for:

  • JavaScript errors (frontend)
  • API errors (backend)
  • Performance monitoring
  • Release tracking

Security Considerations

HTTPS Everywhere

  • Vercel: Automatic HTTPS
  • Railway: Automatic HTTPS
  • Cloudflare: Force HTTPS redirect

Secrets Management

  • NEVER commit secrets to git
  • Store in platform dashboards (Vercel, Railway)
  • Rotate JWT secrets quarterly
  • Rotate 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 IP
  • Auth: 5 req/min per IP
  • Reports: 10 req/min per user

Backup & Disaster Recovery

Database Backups (Railway)

  • Automated: Daily backups, 30-day retention
  • Manual: Export via pg_dump before major migrations
  • Restore: Railway Dashboard → Database → Backups → Restore

File Backups (Cloudflare R2)

  • Strategy: Receipts/PDFs linked in database, R2 is source of truth
  • Retention: 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 minutes
    • Backend redeploy: 5 minutes
    • Frontend redeploy: 2 minutes
    • DNS propagation: 5 minutes (cached)

Recovery Point Objective (RPO)

  • Target: 24 hours (maximum data loss acceptable)
  • Achieved via: Daily database backups


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 NameVersionPurpose
bilko-cors-originsv2Comma-separated list of allowed CORS origins
bilko-database-urllatestCloud SQL connection string (password reset 2026-04-16)
bilko-jwt-refresh-secretlatestJWT 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 from bilko-cors-origins:latest
  • SESSION_COOKIE_SECURE=true
  • NODE_ENV=production
  • DATABASE_URL → pulled from bilko-database-url:latest

Custom Domain Setup

Current Domain

  • Host: bilko-demo.alai.no
  • Mapped to: bilko-web Cloud 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

  1. Domain must be verified in Google Search Console by the GCP account owner
  2. DNS provider access (one.com for alai.no, Vercel for basicconsulting.no)
  3. gcloud CLI 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

  1. Authenticate via WIF
  2. Build Docker images (api + web)
  3. Push to Google Container Registry
  4. Deploy to Cloud Run (europe-north1)
  5. Run smoke tests (Playwright E2E)

Testing

Backend Tests

  • Framework: Vitest
  • Location: apps/api/src/**/*.test.ts
  • Command: pnpm test (from apps/api/)

End-to-End Tests

  • Framework: Playwright
  • Location: apps/e2e/tests/
  • Command: pnpm test (from apps/e2e/)
  • Runs in CI: Yes (on every deploy)

Recent Fixes (2026-04-16)

Commits

  • a62b7f6NoCloud infrastructureRun deployedservice yetnames align: bilko-staging-* → bilko-*
  • 9b1ced1 — Backend: added currency field to invoice list, added /api/v1/settings/profile endpoint
  • 73693d4 — Frontend: avatar initials fallback, invoice step indicator, chat widget ARIA labels

Key Changes

  1. Service Naming: Production services now named bilko-api and bilko-web (no -staging suffix)
  2. API Enhancements: Invoice list now includes currency, new profile settings endpoint
  3. 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  environment

HTTP │ Express + TS │ │ europe-north1 │ │ europe-north1 │ └─────────────────────────────┘ └────────┬─────────┘ │ ▼ ┌─────────────────┐ │ Cloud SQL │ │ PostgreSQL 16 │ │ europe-north1 │ └─────────────────┘