Operational guide for deploying and maintaining the fullstack template in staging and production.
- Prerequisites
- Environment variables
- First-time production setup
- Pre-launch checklist
- Triggering a deploy
- Deploying the backend
- Deploying the web app
- Deploying the mobile app
- Database migrations
- Secrets management
- Rollback procedures
- Monitoring and alerting
- Common operations
| Tool | Purpose | Min version |
|---|---|---|
| Docker | Local Postgres + Prometheus/Grafana | 24 |
| Go | Build the backend binary | 1.25 |
| Node.js | Build the web app | 22 |
| pnpm | Web package manager | any |
gh CLI |
GitHub operations | 2 |
psql |
Database access / inspection | any |
Production accounts required:
- Google Firebase project with Authentication enabled
- Cloudflare R2 bucket (if file storage is used)
- Sentry project for error tracking (optional)
- Mailjet account for transactional email (optional)
- A PostgreSQL host: Supabase, Neon, AWS RDS, or Railway
- A Redis host: Upstash, Redis Cloud, or Railway
| Variable | Required | Description |
|---|---|---|
PORT |
Yes | HTTP server port (default 8080) |
ENV |
Yes | local / staging / production |
BLUEPRINT_DB_HOST |
Yes | PostgreSQL host |
BLUEPRINT_DB_PORT |
Yes | PostgreSQL port (default 5432) |
BLUEPRINT_DB_DATABASE |
Yes | Database name |
BLUEPRINT_DB_USERNAME |
Yes | Database user |
BLUEPRINT_DB_PASSWORD |
Yes | Database password |
BLUEPRINT_DB_SSLMODE |
Yes | disable (local) / require (production) |
FIREBASE_PROJECT_ID |
Yes | Firebase project ID |
FIREBASE_SERVICE_ACCOUNT_JSON |
Yes | Service account key (single-line JSON) |
REDIS_URL |
No | Redis connection URL — omit to disable cache/queues |
CORS_ALLOWED_ORIGINS |
Yes | Comma-separated list, e.g. https://app.example.com |
SENTRY_DSN |
No | Sentry DSN — omit to disable |
RATE_LIMIT_RPS |
No | Requests/sec per IP (default: disabled) |
MAILJET_API_KEY |
No | Mailjet API key — omit to disable email |
R2_ACCOUNT_ID |
No | Cloudflare R2 account ID — omit to disable storage |
| Variable | Required | Description |
|---|---|---|
AUTH_SECRET |
Yes | openssl rand -base64 32 |
FIREBASE_PROJECT_ID |
Yes | Same project as backend |
FIREBASE_SERVICE_ACCOUNT_JSON |
Yes | Same service account as backend |
NEXT_PUBLIC_FIREBASE_API_KEY |
Yes | Firebase client config |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN |
Yes | Firebase client config |
NEXT_PUBLIC_FIREBASE_PROJECT_ID |
Yes | Firebase client config |
BACKEND_URL |
Yes | Go backend URL (server-side only) |
NEXT_PUBLIC_BACKEND_URL |
Yes | Go backend URL (client-side) |
NEXT_PUBLIC_SENTRY_DSN |
No | Sentry DSN |
# PostgreSQL — example using Supabase CLI
supabase init && supabase start # local reference
# For production: create a Supabase project at supabase.com
# Redis — example using Upstash
# Create a database at upstash.com → copy the Redis URL- Go to Firebase Console → Add project.
- Enable Authentication → Sign-in methods → Google + Email/Password.
- Add an Android app: package
com.company.template→ downloadgoogle-services.json. - Add a web app → copy the client config into
web/.env.local. - Project settings → Service accounts → Generate new private key → download JSON.
- Minify to a single line:
jq -c . < service-account.json→ set asFIREBASE_SERVICE_ACCOUNT_JSON.
# Point to production DB
export BLUEPRINT_DB_HOST=<prod-host>
export BLUEPRINT_DB_USERNAME=<prod-user>
export BLUEPRINT_DB_PASSWORD=<prod-password>
export BLUEPRINT_DB_DATABASE=<prod-db>
export BLUEPRINT_DB_SSLMODE=require
cd backend && make migrate-upRun through this before going live with any project based on this template.
-
ENVis set toproduction— enables JSON logging and disables/debug/pprofand/admin/queues -
AUTH_SECRETis a strong random value:openssl rand -base64 32 -
CORS_ALLOWED_ORIGINSlists your exact domain(s) — never*in production -
BLUEPRINT_DB_SSLMODEisrequire -
RATE_LIMIT_RPSis configured to a sensible value for your expected traffic
- Database backups are enabled on your PostgreSQL host (Supabase, Neon, and RDS all have this in their dashboard — turn it on)
- Run
make migrate-upagainst the production database before the first deploy, and on every subsequent deploy that includes migrations - If using R2 file uploads, CORS rules on the R2 bucket allow requests from your web domain
- Firebase Authentication is enabled with only the sign-in methods your app uses
- Firebase security rules are reviewed (default rules may be too permissive)
-
FIREBASE_SERVICE_ACCOUNT_JSONin production is the production project's key, not a dev key
-
SENTRY_DSN(backend) andNEXT_PUBLIC_SENTRY_DSN(web) are set — you want errors reported from day one - The
/healthendpoint returns200after deploying (confirms DB connectivity) - Grafana is accessible and the Backend Overview dashboard shows live data (
http://<host>:3001)
-
google-services.jsonin the release build points to the production Firebase project - Release APK/AAB is signed with the production keystore (not the debug keystore)
- Staged rollout is configured in Google Play Console before full release
This template uses two branches as deploy triggers, kept separate from main:
| Branch | Advances | Trigger |
|---|---|---|
staging |
Automatically, on every merge to main |
GitHub Actions (.github/workflows/sync-staging.yml) |
production |
Manually, whenever you choose | make deploy-prod |
Configure your hosting platforms (Vercel, Railway, Fly.io, etc.) to auto-deploy from these branches — staging for the staging environment, production for production — rather than from main. That way merges to main land safely, staging always reflects the latest main, and a production deploy only fires when you explicitly advance production.
.github/workflows/sync-staging.yml runs on every push to main (i.e. every merge) and calls scripts/sync-staging.sh, which:
- Fetches
origin/mainandorigin/staging. - Checks out (or creates) a local
stagingbranch reset toorigin/staging. - Rebases it onto
origin/main. - Force-pushes
stagingwith--force-with-lease.
There is no confirmation prompt — unlike production, staging is meant to always match main. If the rebase hits a conflict (only possible if someone committed directly to staging), the job aborts the rebase and fails without pushing; fix it manually with the same commands scripts/sync-staging.sh prints on failure, or delete stray commits from staging so it stops diverging from main.
You can also trigger the workflow manually from the Actions tab (workflow_dispatch), or run bash scripts/sync-staging.sh locally.
The workflow checks out with a personal access token (PAT), not the default GITHUB_TOKEN. This matters because of GitHub's built-in loop-prevention rule: pushes made with the default GITHUB_TOKEN do not trigger other on: push workflows. If your CD workflow deploys on push: branches: [staging], a GITHUB_TOKEN-authenticated push here would update the staging ref without ever firing that deploy — it would look like the sync succeeded but nothing would actually deploy. A PAT belonging to a real account avoids that.
To set it up:
- Generate a PAT with push access to this repo — a fine-grained token scoped to this repo with Contents: Read and write is enough; a classic token needs the
reposcope. - Add it as a repository secret named
SYNC_STAGING_PAT(Settings → Secrets and variables → Actions → New repository secret). - If
stagingis a protected branch, make sure the PAT's account is allowed to bypass (or is exempt from) those protection rules, otherwise the force-push will still be rejected.
The commit identity used for the sync (git config user.name/user.email) is resolved from github.actor — whoever triggered the workflow (typically whoever merged the PR) — not hardcoded, so this works the same for any fork of this template.
Advance production to match main with:
make deploy-prodThis runs scripts/deploy-prod.sh (macOS/Linux) or scripts/deploy-prod.ps1 (Windows) via the deploy-prod Makefile target, which handles both platforms automatically. The script:
- Aborts if the working tree is dirty — commit, stash, or discard changes first.
- Fetches
origin/mainandorigin/production. - Checks out (or creates) a local
productionbranch reset toorigin/production. - Rebases it onto
origin/main. - Shows the commits about to ship and prompts
Continue? [y/N]before pushing. - Force-pushes
productionwith--force-with-lease(never a bare--force), so the push fails instead of clobbering someone else's work ifproductionmoved remotely since the last fetch.
Set CONFIRM=yes to skip the interactive prompt (e.g. from CI):
CONFIRM=yes make deploy-prodIf the rebase hits conflicts, the script leaves the branch mid-rebase and prints next steps — resolve the conflicts, then run git rebase --continue followed by git push --force-with-lease origin production, or git rebase --abort to back out. The original branch is restored automatically on both success and abort.
make deploy-prod only advances the branch — it does not run database migrations or build anything. Apply pending migrations against production before running it; see Database migrations.
The backend compiles to a single binary. Choose one deployment model:
FROM golang:1.25-alpine AS build
WORKDIR /app
COPY . .
RUN go build -o /server ./cmd/api
FROM alpine:3.21
COPY --from=build /server /server
EXPOSE 8080
CMD ["/server"]docker build -t fullstack-backend:latest .
docker push <registry>/fullstack-backend:latest
# Deploy image to Railway, Fly.io, Cloud Run, ECS, etc.cd backend
GOOS=linux GOARCH=amd64 go build -o server-linux ./cmd/api
scp server-linux user@host:/opt/app/server
ssh user@host "systemctl restart app"Inject all backend environment variables as process environment (not a .env file) in production. Most PaaS platforms have a secrets/env UI. For self-hosted, use systemd EnvironmentFile or a secrets manager (AWS SSM, Doppler, 1Password Secrets Automation).
# Install Vercel CLI once
pnpm add -g vercel
cd web
vercel --prodSet all environment variables in the Vercel dashboard under Settings → Environment Variables. Variables prefixed NEXT_PUBLIC_ are embedded at build time.
cd web
pnpm build # outputs .next/
# Use the official Next.js Docker image or a Dockerfile with `next start`The BACKEND_URL must be the internal URL of the backend container (e.g. http://backend:8080) when both run in the same Docker network.
cd mobile
./gradlew assembleDebug
# Output: app/build/outputs/apk/debug/app-debug.apk- Generate a signing keystore (once):
keytool -genkey -v -keystore release.jks -alias release -keyalg RSA -keysize 2048 -validity 10000
- Add signing config to
mobile/app/build.gradle.kts(do not commit the keystore). - Build signed release bundle:
cd mobile ./gradlew bundleRelease # Output: app/build/outputs/bundle/release/app-release.aab
- Upload the
.aabto Google Play Console.
Store the keystore as a base64 secret:
base64 -w 0 release.jks > release.jks.b64
# Add content as GitHub secret KEYSTORE_BASE64In the workflow:
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > mobile/app/release.jksRule: always apply migrations before deploying a new backend version.
# Check pending migrations
cd backend && make migrate-status
# Apply all pending migrations
cd backend && make migrate-up
# Apply only the next migration (safer for large schemas)
cd backend && make migrate-up-one
# Roll back the last migration
cd backend && make migrate-down
# View current version
cd backend && make migrate-versionNever edit or delete an applied migration. If a migration was applied incorrectly, add a new migration to correct it.
For tables with millions of rows, prefer:
- Additive changes first (add nullable column, add index
CONCURRENTLY). - Deploy code that handles both old and new schema.
- Backfill data in batches.
- Add
NOT NULLconstraint in a separate migration after backfill.
Copy .env.example to .env (backend) and .env.local (web). Never commit either file.
cp backend/.env.example backend/.env
cp web/.env.example web/.env.local| Platform | Recommended approach |
|---|---|
| Vercel | Environment Variables UI → mark secrets as "Production" only |
| Railway | Variables tab per service |
| Fly.io | fly secrets set KEY=value |
| AWS | SSM Parameter Store + IAM role |
| Self-hosted | Doppler, 1Password Secrets, or systemd EnvironmentFile |
Rotation procedure:
- Generate new secret value.
- Add new value to secrets manager.
- Redeploy the service (picks up new value from environment).
- Revoke the old value.
# Redeploy the previous image tag
docker pull <registry>/fullstack-backend:<previous-tag>
# Update the deployment to point to the previous tagIf the new version introduced a migration that needs to be undone:
cd backend && make migrate-down # rolls back last applied migration
# Then redeploy previous binaryOn Vercel: Deployments tab → select the previous successful deployment → Promote to Production.
On self-hosted: redeploy the previous Docker image or binary.
Mobile apps cannot be forced-rollback for users who have already updated. Options:
- Use a feature flag to disable the problematic feature remotely.
- Release a hotfix version to the Play Store (fastest path: ~1-2 hrs for expedited review).
- Halt the staged rollout in Google Play Console before it reaches 100%.
cd backend && make docker-run # starts Postgres + Prometheus + Grafana- Prometheus:
http://localhost:9090 - Grafana:
http://localhost:3001(default credentials:admin/admin, override withGRAFANA_ADMIN_USER/GRAFANA_ADMIN_PASSWORD) - Metrics endpoint:
http://localhost:8080/metrics(restricted to loopback/private IPs in production)
Set SENTRY_DSN (backend) and NEXT_PUBLIC_SENTRY_DSN (web). Errors are captured automatically; the backend uses the Gin middleware, the web uses the Next.js Sentry plugin.
Available at /debug/pprof/ — always restricted to loopback and RFC 1918 addresses:
# CPU profile (30 s)
go tool pprof http://localhost:8080/debug/pprof/profile
# Heap snapshot
go tool pprof http://localhost:8080/debug/pprof/heap
# Goroutine dump
curl http://localhost:8080/debug/pprof/goroutine?debug=1# Docker / Railway / Fly.io
docker restart <container>
# or trigger a redeploy in the platform UIpsql "postgresql://<user>:<password>@<host>:5432/<database>?sslmode=require"In debug mode, the Asynqmon UI is at http://localhost:8080/admin/queues. For production, either:
- Run the backend locally pointed at the production Redis URL, or
- Use the Asynq CLI:
asynq dash --uri=<redis-url>
redis-cli -u <REDIS_URL> FLUSHDB # clears the current database onlyThe backend is stateless — multiple instances can run behind a load balancer. Ensure:
REDIS_URLis set (rate limiter uses Redis for distributed counting).- Database connection pool size (
BLUEPRINT_DB_*) is tuned per instance. - The load balancer forwards
X-Forwarded-Forand is listed in trusted proxies if needed.