🌐 Language: English · Tiếng Việt · 中文 · 한국어
Multi-stage Dockerfile (located in project root Dockerfile):
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install
COPY . .
RUN pnpm --filter @mobile-boilerplate/api build
# Stage 2: Runtime
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /build/apps/api/dist ./dist
COPY --from=builder /build/apps/api/node_modules ./node_modules
COPY --from=builder /build/apps/api/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]Build locally:
docker build -t mobile-boilerplate-api:latest .Build in CI (GitHub Actions):
- Triggered on push to
mainorbetabranch (after api-ci passes) - Builds docker image
- Tags with git sha + branch tag
- Pushes to registry (e.g., Docker Hub, ECR, GCR)
Set CI secrets:
DOCKER_REGISTRY—docker.ioor private registry URLDOCKER_USERNAME— registry usernameDOCKER_PASSWORD— registry password or token
Production (.env):
DATABASE_URL="postgresql://user:pass@prod.supabase.co:5432/boilerplate"
DIRECT_URL="postgresql://user:pass@prod.supabase.co:5432/boilerplate"
SKIP_DB=false
NODE_ENV=production
LOG_LEVEL=info
API_PORT=3000
THROTTLE_LIMIT=100
THROTTLE_TTL=60000
JWT_SECRET="<use-secrets-manager>"Staging (.env):
DATABASE_URL="postgresql://user:pass@staging.supabase.co:5432/boilerplate"
DIRECT_URL="postgresql://user:pass@staging.supabase.co:5432/boilerplate"
SKIP_DB=false
NODE_ENV=staging
LOG_LEVEL=debug
API_PORT=3000
JWT_SECRET="<use-secrets-manager>"Production deployment options:
| Platform | Method | Notes |
|---|---|---|
| Docker | docker run -e DATABASE_URL=... mobile-boilerplate-api |
Simple, portable |
| Kubernetes | Use Helm chart or manifests | Auto-scaling, HA |
| Cloud Run (GCP) | Push image, set env vars | Serverless, auto-scaling |
| ECS (AWS) | Task definition + service | Managed containers |
| Railway | Connect GitHub, auto-deploy | Simplest for small projects |
Secrets management:
- Never commit
.envfile - Use platform-specific secrets (GitHub Secrets, AWS Secrets Manager, Vault)
- Inject at runtime via env vars or volume mounts
- Rotate secrets regularly
Automated schema migrations (Prisma):
# Generate latest migration files
pnpm --filter @mobile-boilerplate/api prisma:migrate deployIn CI/CD:
- Run before starting server (zero-downtime via Prisma)
- Rollback: git revert commit, redeploy (Prisma tracks migrations)
Manual migration on Supabase:
- Go to SQL Editor
- Copy migration file content from
apps/api/prisma/migrations/<timestamp>-<name>/migration.sql - Run in editor
- Verify schema updated
Endpoint: GET /health (no authentication required)
Response:
{
"data": {
"status": "ok",
"database": "connected"
},
"meta": { "requestId": "..." },
"error": null
}Used by:
- Kubernetes liveness probe:
http://pod:3000/health - Load balancer health check
- Monitoring alerts
Structured logging (Pino):
- All logs output as JSON (parse with ELK, Datadog, CloudWatch)
- Each request has requestId for tracing
- Log level configurable via
LOG_LEVELenv
Example log entry:
{
"level": 20,
"time": "2026-04-27T00:00:00.000Z",
"pid": 1234,
"hostname": "pod-xyz",
"req": { "method": "GET", "url": "/hello" },
"res": { "statusCode": 200 },
"duration": 5,
"requestId": "uuid-v4",
"msg": "request completed"
}Set up log aggregation:
- Datadog Agent → logs to Datadog
- AWS CloudWatch agent → logs to CloudWatch
- ELK Stack → logs to Elasticsearch
Build signed APK (production):
cd apps/mobile
fvm flutter build apk --release --dart-define=FLAVOR=prod
# Output: build/app/outputs/apk/release/app-release.apkBuild App Bundle (Play Store, recommended):
fvm flutter build appbundle --release --dart-define=FLAVOR=prod
# Output: build/app/outputs/bundle/release/app-release.aabSigning:
- Create keystore (one-time):
keytool -genkey -v -keystore ~/my-release-key.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias my-key-alias - Reference in
apps/mobile/android/key.properties:storeFile=/path/to/my-release-key.jks storePassword=**** keyPassword=**** keyAlias=my-key-alias - Build will auto-sign
Upload to Play Store:
- Use Play Console (manual)
- Or fastlane:
fastlane supply --aab=path/to/app-release.aab --package_name=com.yourcompany.app
Build signed IPA (production):
cd apps/mobile
fvm flutter build ipa --release --dart-define=FLAVOR=prod
# Output: build/ios/ipa/mobile_boilerplate.ipaSigning:
- Create or update provisioning profiles in Apple Developer
- Update
ios/Runner.xcconfig:DEVELOPMENT_TEAM = ABC123XYZ CODE_SIGN_IDENTITY = iPhone Distribution - Build will auto-sign
Upload to TestFlight/App Store:
- Use Xcode Organizer (manual)
- Or fastlane:
fastlane deliver --ipa=path/to/mobile_boilerplate.ipa
Version is in pubspec.yaml:
version: 0.1.0+1 # 0.1.0 = semantic version, 1 = build numberUpdate process:
- Edit
versioninpubspec.yaml - Commit + push to
mainbranch - semantic-release auto-tags and releases
- CI builds APK/IPA with new version
Configured in .releaserc.cjs at project root:
-
Conventional commits parsed:
feat:→ minor version bumpfix:→ patch version bumpBREAKING CHANGE:→ major version bump
-
Changelog auto-generated (appended to
docs/project-changelog.md) -
Git tag created: e.g.,
v1.2.0orv1.2.0-beta.1 -
Release published:
- Main branch → production release (v1.2.0)
- Beta branch → prerelease (v1.2.0-beta.1)
-
Artifacts:
- Git tag pushed
- Release notes on GitHub
- Docker image tagged + pushed (if api changed)
- Mobile app uploaded (if mobile changed, manual step)
semantic-release only runs if:
api-ciworkflow passed (backend tests, lint, build)mobile-ciworkflow passed (frontend tests, lint, build)- Branch is
mainorbeta
Example: If you push a feature to main that breaks a test, semantic-release blocks the release until you fix it.
| Branch | Release Type | Example Tag |
|---|---|---|
main |
Production | v1.2.0 |
beta |
Prerelease | v1.2.0-beta.1 |
feature/* |
None (CI only) | — |
develop |
None (CI only) | — |
- Backend:
DATABASE_URL=postgresql://localhost/boilerplateorSKIP_DB=true - Mobile:
API_BASE_URL=auto,FLAVOR=dev
- Backend: Supabase staging project,
LOG_LEVEL=debug - Mobile:
API_BASE_URL=https://staging-api.yourcompany.com,FLAVOR=staging
- Backend: Supabase production,
LOG_LEVEL=info, monitoring enabled - Mobile:
API_BASE_URL=https://api.yourcompany.com,FLAVOR=prod
Mobile config is build-time (flutter build --dart-define=FLAVOR=prod). Backend config is runtime (env vars at deploy time).
Backend (Docker-based):
- Keep previous image tagged:
mobile-boilerplate-api:v1.1.0(old),v1.2.0(new) - If production breaks, redeploy old image:
docker run -e DATABASE_URL=... mobile-boilerplate-api:v1.1.0
- OR use git:
git checkout v1.1.0 && docker build ...
Mobile:
- Play Store: Use "Manage releases" → previous version as active
- App Store: Use TestFlight or previous release in App Store Connect
Database (Prisma):
- Migrations are versioned in git
- To rollback schema:
git revert <migration-commit>, thenprisma migrate deploy - Caution: Data loss possible; backup before major migrations
Setting ENABLE_SWAGGER=true outside NODE_ENV=development relaxes the
Content Security Policy: script-src allows 'unsafe-inline' so the Swagger
UI can render its inline scripts.
Trade-off: XSS protection is materially weakened. Any user-controllable content reflected in a response becomes potentially executable.
Rule:
- ✅ Use
ENABLE_SWAGGER=truefor short, supervised debug sessions in staging - ❌ Never leave it on in production
- ❌ Never set in long-lived staging environments exposed to real users
If you need Swagger access against a non-dev API for routine work, use a
stand-alone Swagger UI client (browser extension, Postman, Swagger Editor)
pointed at /api-docs/json — keep server-side CSP strict.
If your API runs behind a load balancer, CDN, or reverse proxy (Cloudflare, ALB, Render, Fly.io, Nginx, etc.), set:
TRUST_PROXY=1 # 1 hop — most common
TRUST_PROXY=2 # CDN → LB → app — set to actual hop countThis makes req.ip resolve to the real client IP from X-Forwarded-For instead
of the proxy's IP. Critical for:
- Rate limiting (Throttler keys requests by IP)
- Audit logs
- Security analysis
Footgun: never set TRUST_PROXY higher than the real hop count.
Higher value = X-Forwarded-For becomes spoofable, attacker can fake any IP.
BODY_LIMIT=1mb covers JSON/urlencoded bodies. For file uploads:
- Do NOT raise
BODY_LIMITto absorb files (affects all routes). - Use
multerper-route with explicit limit:FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }).
Set up alerts for:
- HTTP 5xx error rate > 1%
- Request latency p95 > 500ms
- Database connection pool exhausted
- Disk usage > 80%
- Memory usage > 85%
Tools:
- Datadog, New Relic, Prometheus, CloudWatch
- Set thresholds in monitoring dashboard
- Alert via Slack, PagerDuty
Last updated: April 2026