Deploy a Flutter web frontend (Cloudflare Pages) + Dart shelf backend (GCP Cloud Run) with automated CI/CD via GitHub Actions, managed by OpenTofu.
This template is designed for single-admin apps — personal tools, dashboards, and solo projects where you are the only user. Authentication is a simple email check against a single admin address. See Growing Beyond Single-Admin if you need multi-user support.
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ 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)
In a new project (empty directory), use git clone https://github.com/manuelschurr/flutter_bootstrap.git . --depth 1 to copy the contents of this repository into your new project. rm -rf .git removes the git repository so you can initialize a new repo.
After 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
├── providers/
│ └── auth_provider.dart Auth state (ChangeNotifier)
├── screens/
│ ├── auth_page.dart Login page UI
│ └── main_page.dart Post-login landing page
└── services/
└── auth_service.dart Backend API client (singleton)
server/ ← Dart backend
├── bin/
│ └── server.dart Server entry point
├── lib/
│ ├── middleware/
│ │ └── auth_middleware.dart Authentication middleware
│ └── routes/
│ ├── auth_routes.dart Auth endpoints (Google OAuth)
│ └── db_routes.dart DB connectivity/status endpoints
├── Dockerfile Container build for Cloud Run
├── .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 skeleton, 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
- Pick your admin email — the single Google account allowed to sign in. This is wired into the backend as the
ADMIN_EMAILenvironment variable (set viaadmin_emailinterraform.tfvars). The OAuth callback rejects any other email.
- Create a database at neon.tech
- Note the connection string (format:
postgresql://user:pass@host/db?sslmode=require)
- 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)
Backend environment variables (set from Secret Manager on Cloud Run; mirror these in server/.env for local dev):
| Variable | Source | Purpose |
|---|---|---|
ADMIN_EMAIL |
admin_email tfvar |
Single admin Google email — OAuth callback rejects anything else |
DATABASE_URL |
database_url tfvar |
Neon PostgreSQL connection string |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
tfvars | OAuth 2.0 credentials |
GOOGLE_AUTH_REDIRECT_URI |
derived | OAuth callback URL |
FRONTEND_URL |
derived | Frontend URL for post-auth redirect |
ALLOWED_ORIGIN |
derived | CORS origin whitelist |
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:
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- Admin login works
-
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 personal 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 intentionally keeps things simple: one admin, one email check, in-memory sessions. If you need to support multiple users, here's what to change and why.
The current server stores session tokens in a Map in memory. Cloud Run can scale to zero or replace containers at any time, which logs everyone out. Switch to signed JWTs:
- After OAuth, sign a JWT containing the user's ID and expiry using a secret from Secret Manager
- Verify requests by checking the JWT signature — no server-side state needed
- For full session control (revocation, "log out all devices"), pair short-lived JWTs (15 min) with refresh tokens stored in the database
Replace the ADMIN_EMAIL check with a users table:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT,
role TEXT NOT NULL DEFAULT 'user', -- 'user', 'admin', etc.
created_at TIMESTAMPTZ DEFAULT now()
);The OAuth callback should create or look up a user record instead of comparing against a single email.
Currently, "authenticated" = "authorized." With multiple users, add middleware that checks what a user can do — not just that they're logged in. Start with role-based access (admin vs. user) and expand to resource-level checks (users can only access their own data) as needed.
Add a user_id column to every user-scoped table and enable Postgres RLS so the database enforces isolation:
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation ON notes
USING (user_id = current_setting('app.current_user_id')::uuid);Set the session variable in your middleware on every request. This way, even a buggy query can't leak data across users.
If users need to collaborate in shared workspaces (e.g., a user creates a team and invites others), you're adding multi-tenancy on top of multi-user. The key additions:
- Tenants table — an
organizationsorteamstable, plus a join table (org_memberships) linking users to orgs with a role (owner, member, viewer) - Scoping shifts from
user_idtotenant_id— shared data belongs to the tenant, not a single user. Your RLS policies filter bytenant_idinstead of (or in addition to)user_id - Invitations flow — an
invitationstable with token, email, expiry, and org reference. The invite link signs the user up (or links an existing account) and adds them to the org - Context switching — users who belong to multiple orgs need a way to switch between them. Store the "active org" in the JWT or as a client-side selection, and pass it to the DB session variable for RLS
Start with a single-tenant-per-user model (every user gets one personal org) and add team support later. This avoids a separate code path for "personal" vs. "team" data.
Update your Cloud Run config for multi-user traffic:
- Increase
max-instancesandmemorybased on expected load - Add connection pooling (Neon supports this via its pooler endpoint)
- Consider
min-instances: 1to avoid cold starts for active apps