Skip to content

manuelschurr/flutter_bootstrap

Repository files navigation

Flutter Web and Mobile — Infrastructure Bootstrap Template

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.

Architecture

 ┌──────────────┐     ┌─────────────────┐     ┌──────────────┐
 │  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)

How to use this template

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.


Prerequisites (install once)

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).


Step-by-step Setup

1. Manual prep (web consoles)

Do these first — you need the values for terraform.tfvars.

1.1 Cloudflare: domain + API token

  1. 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)
  2. Note your Account ID and Zone ID
    • Dashboard → your domain → Overview → right sidebar
  3. Create an API token

1.2 GitHub: Personal Access Token

  1. Go to github.com/settings/tokens
  2. Create a Fine-grained token scoped to your repo
  3. 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

1.3 Google Cloud: project + OAuth credentials

  1. 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)
  2. Configure the OAuth consent screen
    • Go to APIs & Services → OAuth consent screen
    • Set app name, support email, etc.
  3. Create an OAuth 2.0 Client ID (Web application)
  4. Pick your admin email — the single Google account allowed to sign in. This is wired into the backend as the ADMIN_EMAIL environment variable (set via admin_email in terraform.tfvars). The OAuth callback rejects any other email.

1.4 Neon: database

  1. Create a database at neon.tech
  2. Note the connection string (format: postgresql://user:pass@host/db?sslmode=require)

1.5 GitHub: create your repository

  1. Create a new (private) GitHub repo
  2. Note the owner/repo name (e.g. yourname/myproject)

2. Configure

cd tofu/
cp terraform.tfvars.example terraform.tfvars

Open terraform.tfvars and fill in all values. Everything you need comes from step 1.

Note: project_name must be lowercase with dashes only (Cloudflare Pages requirement). It's used for naming resources across all providers.

⚠️ Never commit terraform.tfvars — it contains secrets. It's already in .gitignore.


3. Authenticate gcloud

gcloud auth application-default login

This gives the terminal and therefore OpenTofu access to create GCP resources.


4. Apply infrastructure

cd tofu/
tofu init
tofu plan     # Review what will be created
tofu apply    # Create everything

This 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:

  1. Go to Google Cloud Console → Credentials
  2. 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

5. Deploy

Push your code to trigger both workflows:

git add .
git commit -m "Initial deploy"
git push origin main

Both 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.


6. Verify

  1. <CLOUD_RUN_URL>/health{"status": "healthy"}
  2. https://yourdomain.com → your app loads
  3. Admin login works

Gotchas (lessons learned)

  1. Secrets: never use echo piped to gcloudecho adds trailing newlines that silently break authentication. OpenTofu's google_secret_manager_secret_version handles this correctly.

  2. No custom domain for the API — Cloud Run domain mapping requires Google to provision a certificate and verify domain ownership. Using the *.run.app URL directly is simpler and works perfectly (users never see the API URL).

  3. Cloudflare Pages project auto-creation — the wrangler pages project create command in the workflow is idempotent. OpenTofu also creates it, so the workflow create is just a safety net.

  4. Cloud Run free tier — 2M requests/month, 360K GB-seconds, 180K vCPU-seconds. More than enough for personal projects.

  5. GitHub Actions path filters — workflows only trigger when relevant files change. Use workflow_dispatch (manual trigger) if the first push doesn't trigger both.

  6. GITHUB_TOKEN cannot write repository secrets — the built-in Actions token never has permission to manage secrets, even with elevated permissions. That's why CLOUD_RUN_URL is set by Tofu (which uses your PAT) rather than by the workflow.

  7. 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.


Growing Beyond Single-Admin

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.

1. Session management: replace in-memory tokens with JWTs

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

2. Users table + OAuth account linking

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.

3. Authorization middleware

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.

4. Data isolation with Row-Level Security (RLS)

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.

5. Multi-tenancy (teams / organizations)

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 organizations or teams table, plus a join table (org_memberships) linking users to orgs with a role (owner, member, viewer)
  • Scoping shifts from user_id to tenant_id — shared data belongs to the tenant, not a single user. Your RLS policies filter by tenant_id instead of (or in addition to) user_id
  • Invitations flow — an invitations table 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.

6. Infrastructure scaling

Update your Cloud Run config for multi-user traffic:

  • Increase max-instances and memory based on expected load
  • Add connection pooling (Neon supports this via its pooler endpoint)
  • Consider min-instances: 1 to avoid cold starts for active apps

About

Single-admin Flutter web + Dart Shelf + Neon PostgreSQL bootstrap template. Deploys to Cloudflare Pages + GCP Cloud Run via OpenTofu.

Topics

Resources

License

Stars

Watchers

Forks

Contributors