Deploy a Flutter web frontend (Cloudflare Pages) + Dart Shelf backend (GCP Cloud Run) + Neon PostgreSQL with automated CI/CD via GitHub Actions, managed by OpenTofu.
Multi-user by default — Google OAuth sign-in, per-user data isolation via PostgreSQL Row-Level Security, and an email allow-list to control who can sign up.
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Cloudflare │ │ Google Cloud │ │ Neon │
│ Pages │───▶│ Run │────▶│ PostgreSQL │
│ (Frontend) │ │ (Backend API) │ │ (Database) │
│ domain.com │ │ *.run.app │ │ *.neon.tech │
└──────────────┘ └─────────────────┘ └──────────────┘
▲ ▲
│ │
└──── GitHub Actions ──┘
(CI/CD on push to main)
Key features:
- Google OAuth with server-side redirect flow
- Email allow-list (
allowed_emailstable) gates who can register - Row-Level Security — every user-scoped table enforces isolation at the database level
- Two separate Dart projects — Flutter frontend (root) and Dart Shelf backend (
server/), no shared code - Full IaC — OpenTofu provisions GCP, Cloudflare, and GitHub secrets in one
tofu apply
git clone https://github.com/manuelschurr/flutter_bootstrap_multi_user.git . --depth 1
rm -rf .gitAfter copying, your project should look like:
.github/workflows/ ← the CI/CD workflows
├── deploy-frontend.yml Flutter web → Cloudflare Pages
└── deploy-backend.yml Dart server → Cloud Run
lib/ ← Flutter frontend
├── main.dart App entry point + auth token handling
├── screens/
│ ├── auth_page.dart Sign-in page (Google OAuth button)
│ └── main_page.dart Items CRUD UI (list, add, edit, delete)
├── providers/
│ └── auth_provider.dart Auth state (ChangeNotifier via Provider)
└── services/
├── auth_service.dart Auth API client (login, logout, verify)
└── database_service.dart Generic REST client (select, insert, update, delete)
server/ ← Dart backend
├── bin/
│ └── server.dart Server entry point + middleware pipeline
├── lib/
│ ├── db.dart PostgreSQL connection pool + RLS session var
│ ├── middleware/
│ │ └── auth_middleware.dart Bearer token validation
│ └── routes/
│ ├── auth_routes.dart Google OAuth + session management
│ └── db_routes.dart Items CRUD endpoints
├── sql/
│ └── schema.sql Database schema (users, items, allowed_emails)
├── Dockerfile Multi-stage build (dart:stable → scratch)
├── .dockerignore
├── pubspec.yaml
└── pubspec.lock
tofu/ ← IaC, run `tofu` from here
├── main.tf Providers (GCP, Cloudflare, GitHub)
├── variables.tf All input variables
├── terraform.tfvars.example The one file you fill in
├── gcp.tf APIs, Artifact Registry, Cloud Run, Secrets
├── iam.tf Service account + IAM bindings
├── cloudflare.tf Pages project + DNS
├── github.tf GitHub Actions secrets (all of them)
├── outputs.tf Values needed after apply
└── .gitignore Excludes tfvars + state files
web/ ← Flutter web shell
├── index.html
├── manifest.json
├── favicon.png
└── icons/ PWA icons
You can then initialize a git repository and start building on top of this template.
| Tool | Install | Verify |
|---|---|---|
| OpenTofu | opentofu.org/docs/intro/install | tofu --version |
| gcloud CLI | cloud.google.com/sdk/docs/install | gcloud --version |
| Git | git-scm.com | git --version |
Accounts needed: Google Cloud, Cloudflare (free), GitHub, Neon (free tier).
Do these first — you need the values for terraform.tfvars.
- Add your domain to Cloudflare (if not already)
- dash.cloudflare.com → Add a site → enter your domain
- Update your domain registrar's nameservers to the ones Cloudflare gives you
- Wait for DNS propagation (~minutes to hours)
- Note your Account ID and Zone ID
- Dashboard → your domain → Overview → right sidebar
- Create an API token
- dash.cloudflare.com/profile/api-tokens
- Create a Custom token with permissions: Account → Cloudflare Pages → Edit and Zone → DNS → Edit
- Go to github.com/settings/tokens
- Create a Fine-grained token scoped to your repo
- Under Repository permissions, enable: Secrets (read/write), Actions (read/write), Metadata (read)
- Note: "Secrets" here means Actions secrets — do not confuse with "Codespace secrets" which is a different permission
- Create a GCP project at console.cloud.google.com
- Link a billing account (free tier covers small projects)
- Note the Project ID (shown on the project dashboard, may have numbers appended)
- Configure the OAuth consent screen
- Go to APIs & Services → OAuth consent screen
- Set app name, support email, etc.
- Create an OAuth 2.0 Client ID (Web application)
- Go to console.cloud.google.com/apis/credentials
- For now, add only localhost redirect URIs:
http://localhost:8080/api/auth/google/callback
- Note the Client ID and Client Secret
- Create a database at neon.tech
- Note the connection string (format:
postgresql://user:pass@host/db?sslmode=require) - Apply the schema:
psql $DATABASE_URL -f server/sql/schema.sql
- Create a new (private) GitHub repo
- Note the owner/repo name (e.g.
yourname/myproject)
cd tofu/
cp terraform.tfvars.example terraform.tfvarsOpen terraform.tfvars and fill in all values. Everything you need comes from step 1.
Note:
project_namemust be lowercase with dashes only (Cloudflare Pages requirement). It's used for naming resources across all providers.
⚠️ Never committerraform.tfvars— it contains secrets. It's already in.gitignore.
gcloud auth application-default loginThis gives the terminal and therefore OpenTofu access to create GCP resources.
cd tofu/
tofu init
tofu plan # Review what will be created
tofu apply # Create everythingThis creates (inside your existing GCP project):
- APIs enabled (Cloud Run, Artifact Registry, Secret Manager)
- Artifact Registry Docker repository
- Cloud Run service (skeleton — CI deploys the actual image)
- Secrets in Secret Manager (no trailing newlines!)
- GitHub Actions service account + IAM roles
- Cloudflare Pages project + DNS records
- All GitHub repository secrets (auto-populated, including
CLOUD_RUN_URL)
After apply, run tofu output cloud_run_url to see the backend URL.
Configure Google OAuth redirect URIs — the Cloud Run URL is available immediately (Tofu creates the service with a placeholder image), so OAuth will work on the very first deploy:
- Go to Google Cloud Console → Credentials
- Edit your OAuth 2.0 Client ID and add:
- Authorized redirect URIs:
<CLOUD_RUN_URL>/api/auth/google/callback - Authorized JavaScript origins:
https://yourdomain.com
- Authorized redirect URIs:
Before anyone can sign in, their email must be in the allowed_emails table:
psql $DATABASE_URL -c "INSERT INTO allowed_emails (email) VALUES ('user@example.com');"Push your code to trigger both workflows:
git add .
git commit -m "Initial deploy"
git push origin mainBoth workflows read all configuration from GitHub secrets (set by Tofu). No placeholders or manual URL wiring needed.
- Backend (
server/**changes): builds Docker image, pushes to Artifact Registry, deploys to Cloud Run - Frontend (
lib/**,web/**,pubspec.*changes): builds Flutter web with the Cloud Run API URL baked in, deploys to Cloudflare Pages
Use workflow_dispatch (manual trigger) if path filters don't match on the first push.
<CLOUD_RUN_URL>/health→{"status": "healthy"}https://yourdomain.com→ your app loads- Sign in with a Google account that's in the
allowed_emailstable
cd server
dart pub getCreate a .env file or export the required environment variables:
export ALLOWED_ORIGIN=http://localhost:8080
export FRONTEND_URL=http://localhost:8080
export GOOGLE_CLIENT_ID=<your-google-client-id>
export GOOGLE_CLIENT_SECRET=<your-google-client-secret>
export GOOGLE_AUTH_REDIRECT_URI=http://localhost:8080/api/auth/google/callback
export DATABASE_URL=postgresql://user:pass@host/db?sslmode=requireThen start the server:
dart run bin/server.dartThe backend runs on http://localhost:8080 by default. Override with export PORT=<port>.
flutter pub get
flutter run -d chrome --dart-define=API_BASE_URL=http://localhost:8080API_BASE_URL tells the frontend where the backend is. It defaults to http://localhost:8080 if omitted.
| Command | Description |
|---|---|
flutter analyze |
Lint the frontend |
flutter test |
Run frontend tests |
dart compile exe server/bin/server.dart -o server/bin/server |
AOT-compile the backend |
docker build -t server server/ |
Build the backend Docker image |
psql $DATABASE_URL -f server/sql/schema.sql |
Apply the database schema |
-
Secrets: never use
echopiped to gcloud —echoadds trailing newlines that silently break authentication. OpenTofu'sgoogle_secret_manager_secret_versionhandles this correctly. -
No custom domain for the API — Cloud Run domain mapping requires Google to provision a certificate and verify domain ownership. Using the
*.run.appURL directly is simpler and works perfectly (users never see the API URL). -
Cloudflare Pages project auto-creation — the
wrangler pages project createcommand in the workflow is idempotent. OpenTofu also creates it, so the workflowcreateis just a safety net. -
Cloud Run free tier — 2M requests/month, 360K GB-seconds, 180K vCPU-seconds. More than enough for small projects.
-
GitHub Actions path filters — workflows only trigger when relevant files change. Use
workflow_dispatch(manual trigger) if the first push doesn't trigger both. -
GITHUB_TOKENcannot write repository secrets — the built-in Actions token never has permission to manage secrets, even with elevated permissions. That's whyCLOUD_RUN_URLis set by Tofu (which uses your PAT) rather than by the workflow. -
Cloud Run service managed as skeleton in Tofu — Tofu creates the service (so the URL is known upfront), but
lifecycle { ignore_changes = [template] }lets GitHub Actions manage the actual image, env vars, and scaling config on each deploy.
This template provides a working multi-user foundation. Common next steps:
- Replace in-memory sessions with JWTs — the current in-memory token store resets on Cloud Run container restarts. Use signed JWTs (verified by signature, no server state) for persistent sessions.
- Add roles and authorization — the
userstable has no role column yet. Add one and extendauthMiddlewareto check permissions. - Add new data tables — follow the
itemspattern: add auser_idcolumn, enable RLS, create auser_isolationpolicy, and always passuserIdtoDb.instance.query(). - Multi-tenancy — if users need shared workspaces, add
organizations+org_membershipstables and shift RLS fromuser_idtotenant_id.