Building a multi-tenant SaaS platform from scratch means making hard architectural decisions early: who owns authentication, where do permissions live, how does the backend connect to the auth provider, and how do you ship confidently across two completely separate codebases โ€” a backend API and a mobile/web app?

This post walks through every layer of a real production system: Supabase as the auth backbone, Django + DRF running serverlessly on AWS Lambda, AWS CDK for infrastructure-as-code, and a Flutter app for the client โ€” all wired together with GitHub Actions pipelines that handle testing, deploying, building, and publishing.


The Architecture at a Glance

Production SaaS runtime architecture
Runtime architecture: Flutter โ†’ CloudFront โ†’ Lambda (Django) โ†’ Supabase

Repos:

  • django-project/ โ€” Django + DRF, AWS CDK, GitHub Actions CI + deploy
  • flutter-app/ โ€” Flutter (Android, iOS, web), GitHub Actions build + publish
ConcernChoiceWhy
AuthSupabase (ES256 JWT)Handles OAuth, magic links, email, password reset out of the box
PermissionsEmbedded in JWT app_metadataZero DB round-trips per request; consistent across client and server
ComputeAWS Lambda (Python 3.13)No servers to manage; scales to zero
IaCAWS CDK (Python)Full Python stack, typed constructs
Client stateFlutter BLoCPredictable auth state machine

Part 1: Supabase Integration

Why ES256 (JWKS), Not HS256

Supabase supports both HS256 (shared secret) and ES256 (asymmetric). ES256 is better for a backend you control: the backend only needs the public key โ€” the private key never leaves Supabase. Key rotation is handled automatically via JWKS endpoint.

# common/auth/supabase.py
from jwt import PyJWKClient

JWKSClient = PyJWKClient(
    settings.SUPABASE_JWKS_URL,
    cache_keys=True,          # caches the JWKS in-process
)

cache_keys=True is important for Lambda: each warm instance maintains a local key cache, avoiding a JWKS fetch on every request.

The Authentication Backend

class SupabaseAuthentication(authentication.BaseAuthentication):
    def authenticate(self, request):
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
        if not auth_header.startswith("Bearer "):
            return None

        token = auth_header.split(" ")[1]

        try:
            signing_key = JWKSClient.get_signing_key_from_jwt(token)
            payload = jwt.decode(
                token,
                signing_key.key,
                algorithms=["ES256"],
                audience="authenticated",
            )

            user, _ = User.objects.get_or_create(
                id=payload.get("sub"),
                defaults={
                    "username": payload.get("email", ""),
                    "name": payload.get("user_metadata", {}).get("full_name", ""),
                }
            )

            # Permissions live in the JWT โ€” no extra DB query
            app_metadata = payload.get("app_metadata", {})
            user.org_id = app_metadata.get("org_id")
            user.org_permissions = app_metadata.get("org_permissions", "0")
            user.location_permissions = app_metadata.get("location_permissions", {})
            user.app_metadata = app_metadata

        except jwt.InvalidTokenError:
            raise exceptions.AuthenticationFailed("Invalid token")

        return user, token

Two things to notice:

  1. get_or_create on the Django User model โ€” Supabase is the source of truth for identity. Django's user table is a thin shadow that gives you a model to attach to. You never create users in Django directly.
  2. Permissions are dynamic attributes on the user object โ€” they come from the JWT payload, not from Django's permission tables. This means they are always consistent with what Supabase has, updated on every new token.

Embedding Permissions in the JWT via app_metadata

Supabase JWTs include a custom app_metadata claim you control. This is how the backend injects role-based permission bitmasks that travel with every request at zero cost:

{
  "app_metadata": {
    "org_id": "uuid-of-org",
    "org_permissions": "127",
    "location_permissions": {
      "uuid-of-location-A": "63",
      "uuid-of-location-B": "3"
    }
  }
}

Permissions are stored as strings โ€” not integers โ€” because mobile clients (Flutter) and web clients (JavaScript) both handle large integers inconsistently. The backend always casts to int() before bitwise operations.

Syncing Permissions Back to Supabase

Whenever a role or membership changes in Django, the JWT must be refreshed so the client gets updated permissions. The sync function reads the current DB state and pushes it to Supabase via the Admin API:

class SupaUtils:
    @staticmethod
    def refresh_user_permissions(user_id: str):
        from app.models import OrgMember, LocationMember

        try:
            member = OrgMember.objects.select_related("role", "org").get(
                user_id=user_id, active=True
            )
            org_permissions = str(member.role.permissions) if member.role else "0"
            default_gp = str(member.role.default_location_permissions) if member.role else "0"

            location_permissions = {}
            for loc_member in LocationMember.objects.select_related("role", "location").filter(
                user_id=user_id, location__org=member.org, active=True
            ):
                location_permissions[str(loc_member.location.id)] = (
                    str(loc_member.role.permissions) if loc_member.role else default_gp
                )

            return SupaUtils.update_app_metadata_for_user(
                user_id=user_id,
                org_id=str(member.org.id),
                org_permissions=org_permissions,
                location_permissions=location_permissions,
                org_active=member.org.is_active,
            )

        except OrgMember.DoesNotExist:
            return SupaUtils.update_app_metadata_for_user(
                user_id=user_id,
                org_id="",
                org_permissions="0",
                location_permissions={},
            )

    @staticmethod
    def update_app_metadata_for_user(user_id, org_id, org_permissions, location_permissions, org_active=True):
        url = f"{settings.SUPABASE_URL}/auth/v1/admin/users/{user_id}"
        headers = {
            "Authorization": f"Bearer {settings.SUPABASE_SECRET_KEY}",
            "apikey": settings.SUPABASE_SECRET_KEY,
        }
        payload = {
            "app_metadata": {
                "org_id": org_id,
                "org_permissions": org_permissions,
                "location_permissions": location_permissions,
                "org_active": org_active,
            }
        }
        response = requests.put(url, json=payload, headers=headers, timeout=5)
        if response.status_code >= 400:
            raise RuntimeError(f"Supabase metadata update failed: {response.status_code}")

        # Revoke existing sessions โ€” forces the client to get a fresh JWT
        SupaUtils.revoke_user_sessions(user_id)
        return response.json()

    @staticmethod
    def revoke_user_sessions(user_id: str):
        url = f"{settings.SUPABASE_URL}/auth/v1/admin/users/{user_id}/logout"
        headers = {
            "Authorization": f"Bearer {settings.SUPABASE_SECRET_KEY}",
            "apikey": settings.SUPABASE_SECRET_KEY,
        }
        requests.post(url, headers=headers, timeout=5)

The important detail: after updating app_metadata, you must call the logout endpoint. This revokes the user's refresh tokens, forcing the client to re-authenticate and receive a new JWT with updated permissions. Without this step, the old JWT stays valid until expiry โ€” the user operates with stale permissions.

Bitmask Permissions

Permissions are stored as integers in the database and as strings in the JWT. All checks go through a single utility to avoid bugs:

class PermissionManager:
    @staticmethod
    def has_permission(user_perm: str | int, required: int) -> bool:
        perm = int(user_perm) if isinstance(user_perm, str) else user_perm
        return (perm & required) == required

The check is (user & required) == required โ€” not just user & required. The difference matters: user & required is truthy for any bit overlap, but == required only passes when all required bits are set. Always use the latter.

Configuring Supabase Auth Redirect URLs

One non-obvious production requirement: Supabase redirects (email confirmation, magic link, password reset) default to localhost unless you configure them per environment.

Dashboard โ†’ Authentication โ†’ URL Configuration:

FieldValue
Site URLhttps://app.yourdomain.com
Redirect URLsOne per line per environment
https://app.yourdomain.com/**
https://staging.yourdomain.com/**
yourapp://reset-password          # Flutter deep link scheme
http://localhost:3000/**          # local Flutter/web dev

Without this, email links in production redirect to localhost and silently fail on mobile.


Part 2: Backend CI/CD with GitHub Actions

The backend has four workflow files with a clean separation of concerns.

Workflow 1: CI (ci.yml)

Runs on every push and pull request to main or dev. Spins up a real PostgreSQL service โ€” not SQLite, not mocks โ€” so migration and query tests reflect production behaviour.

name: CI

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main, dev]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_pass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.13'
      - run: pip install -r requirements.txt
      - name: Run tests
        env:
          MODE: Test
        run: python manage.py test

The --health-cmd pg_isready block is essential โ€” without it, the test step can start before Postgres is ready to accept connections.

Workflow 2 & 3: Environment Deploys

deploy_dev.yml and deploy_prod.yml are thin wrappers. They exist purely to name the trigger and pass stage to the shared reusable workflow, keeping the actual deploy logic in one place:

# deploy_prod.yml
name: Deploy to Prod

on:
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag to deploy (e.g. v1.2.3)'
        required: true

jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      stage: prod
      tag: ${{ inputs.tag }}
    secrets: inherit

Production deploys are always manual and tag-based โ€” there is no auto-deploy on push to main. This gives you an explicit gate before anything hits production.

Workflow 4: The Shared Deploy (deploy.yml)

This is where the real work happens. It runs CDK to deploy the Lambda + infrastructure, then invokes the deployed Lambda to run migrations and collect static files.

name: Deploy

on:
  workflow_call:
    inputs:
      stage:
        required: true
        type: string
      tag:
        required: true
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.stage }}    # GitHub environment for secrets

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.tag }}        # always deploy from a tagged commit

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-south-1

      - name: Bootstrap env artifact
        env:
          ARTIFACTS_BUCKET: ${{ inputs.artifacts_bucket }}
        run: bash scripts/bootstrap_env.sh

      - name: CDK Deploy
        run: |
          source $GITHUB_WORKSPACE/.env-artifact/activate.sh
          stage="${{ inputs.stage }}"
          cdk deploy "myapp-${stage}" \
            --context env="${stage^}" \
            --context tag="${{ inputs.tag }}" \
            --require-approval never
        working-directory: cdk

      - name: Run migrations
        run: |
          aws lambda invoke \
            --function-name myapp-${{ inputs.stage }}-admin-tasks \
            --payload '{"command":"migrate","args":["--noinput"]}' \
            --cli-binary-format raw-in-base64-out \
            /tmp/migrate-response.json
          cat /tmp/migrate-response.json

      - name: Collect static files
        run: |
          aws lambda invoke \
            --function-name myapp-${{ inputs.stage }}-admin-tasks \
            --payload '{"command":"collectstatic","args":["--noinput"]}' \
            --cli-binary-format raw-in-base64-out \
            /tmp/collectstatic-response.json
          cat /tmp/collectstatic-response.json

Key design decisions:

Migrations via Lambda invocation, not a separate container. After CDK deploys the new Lambda code, you invoke the already-deployed function to run manage.py migrate. This is the same runtime environment as the production handler โ€” same Python version, same layer, same environment variables. No "migration runner" container to maintain.

admin-tasks Lambda โ€” a second Lambda function that handles long-running management commands (timeout: 5 minutes). The app Lambda has a 30-second timeout, which is too short for migrations on large tables. The admin-tasks function runs the same Django application code but with a different handler and timeout:

# handlers/admin.py
import io
from django.core.management import call_command

def handler(event, context):
    command = event.get("command")
    args = event.get("args", [])

    buf = io.StringIO()
    call_command(command, *args, stdout=buf, stderr=buf)
    return {"output": buf.getvalue()}

bootstrap_env.sh โ€” the most overlooked but most important optimisation. CDK requires both Python and Node.js. Installing them on every run takes 3โ€“4 minutes. The script hashes requirements.txt and package.json, looks for a matching tarball in S3, and either downloads it (fast) or builds and uploads it (slow, once):

DEPS_HASH=$(cat requirements.txt requirements-cdk.txt package.json | sha256sum | cut -c1-16)
ARTIFACT_KEY="myapp-${DEPS_HASH}.tar.gz"

if aws s3 ls "s3://${BUCKET}/artifacts/${ARTIFACT_KEY}" > /dev/null 2>&1; then
    aws s3 cp "s3://${BUCKET}/artifacts/${ARTIFACT_KEY}" /tmp/env.tar.gz
    tar xzf /tmp/env.tar.gz -C .env-artifact/
else
    # build venv + install CDK Node + npm install
    # ... tar and upload
fi

This turns a 4-minute dependency install into a 15-second S3 download on the common path.

Workflow 5: Management Commands on Demand

A fifth workflow lets you invoke any Django management command against any environment from the GitHub UI โ€” useful for data backfills, cache warmups, and debugging:

name: Management Command

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [dev, prod]
      command:
        description: 'e.g. migrate, collectstatic, send_reminders'
      args:
        description: 'JSON array e.g. ["--noinput"]'
        default: '[]'

jobs:
  run:
    environment: ${{ inputs.environment }}
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        ...
      - run: |
          PAYLOAD=$(jq -n \
            --arg cmd "${{ inputs.command }}" \
            --argjson args '${{ inputs.args }}' \
            '{command: $cmd, args: $args}')
          aws lambda invoke \
            --function-name myapp-${{ inputs.environment }}-admin-tasks \
            --payload "$PAYLOAD" \
            --cli-binary-format raw-in-base64-out \
            /tmp/response.json
          cat /tmp/response.json

The CDK Stack

The Lambda layer is the most complex part of the CDK stack because Python packages must be compiled for the Lambda runtime (manylinux2014_x86_64), not the CI runner:

def _create_lambda_layer(self) -> lambda_.LayerVersion:
    return lambda_.LayerVersion(
        self, "DepsLayer",
        compatible_runtimes=[lambda_.Runtime.PYTHON_3_13],
        code=lambda_.Code.from_asset(
            "..",
            bundling=cdk.BundlingOptions(
                image=lambda_.Runtime.PYTHON_3_13.bundling_image,
                command=["bash", "-c",
                    "pip install -r requirements.txt"
                    " -t /asset-output/python"
                    " --only-binary=:all:"
                    " --platform=manylinux2014_x86_64"
                    " --implementation=cp"
                    " --python-version=3.13"
                    # Strip boto3/botocore โ€” already in Lambda runtime
                    " && rm -rf /asset-output/python/boto3"
                    " /asset-output/python/botocore"
                ],
            ),
        ),
    )

The --only-binary=:all: flag forces pip to use pre-built wheels. Combined with the manylinux2014_x86_64 platform target, you get binary extensions compiled for Lambda's glibc environment, not your developer machine.

Removing boto3 and botocore from the layer saves ~50 MB โ€” the Lambda runtime already includes them.

The full stack topology per environment:

Lambda (app) โ”€โ”€โ”€โ”€ Lambda Alias (dev/prod)
                          โ”‚
                    API Gateway (HTTP API)
                          โ”‚
                    CloudFront Distribution
                    โ”œโ”€โ”€ Default: proxy to API Gateway (no cache)
                    โ””โ”€โ”€ /static/*: S3 origin (optimized cache)
                          โ”‚
                    Route53 A-record โ†’ CloudFront

A warmer Lambda runs on a cron schedule to keep the app Lambda warm, preventing cold starts during business hours:

rule = events.Rule(
    self, "WarmerSchedule",
    schedule=events.Schedule.cron(hour="0", minute="0"),
)
rule.add_target(event_targets.LambdaFunction(warmer_fn))

Part 3: Flutter Integration

Auth State with BLoC

The Flutter side uses flutter_bloc to model authentication as a state machine. States are: AuthInitial โ†’ AuthLoading โ†’ Authenticated | Unauthenticated | AuthError.

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AppAuthProvider authProvider;
  late final StreamSubscription _authSub;

  AuthBloc(this.authProvider) : super(AuthInitial()) {
    on<AuthCheckRequested>(_onAuthCheckRequested);
    on<SignInWithEmail>(_onSignInWithEmail);
    on<SignInWithGoogle>(_onSignInWithGoogle);
    on<SignOut>(_onSignOut);
    on<SyncPermissions>(_onSyncPermissions);

    // React to Supabase session changes globally
    _authSub = authProvider.authStateChanges.listen((user) {
      if (user == null && state is Authenticated) {
        add(SignOut());
      }
    });
  }

The authStateChanges stream listener is the bridge between Supabase's session management and your BLoC state. When Supabase revokes a session (e.g. after a permission update on the backend), this triggers automatically and signs the user out โ€” they re-authenticate and get a fresh JWT with updated permissions.

API Client with Automatic JWT Injection

The API class is a thin wrapper over http that reads the current Supabase session and injects the Bearer token on every request:

class API {
  static late String baseUrl;

  static Map<String, String> _headers() {
    String? jwt;
    try {
      jwt = Supabase.instance.client.auth.currentSession?.accessToken;
    } catch (_) {}
    return {
      'Content-Type': 'application/json',
      if (jwt != null && !isJwtExpired(jwt)) 'Authorization': 'Bearer $jwt',
    };
  }

isJwtExpired decodes the JWT locally (without a network call) and checks the exp claim. If expired, no Authorization header is sent โ€” the request fails with 401 on the backend, and the calling code can trigger a token refresh or sign-out.

static Future<ApiResponse<dynamic>> uploadFile(
  String path, {
  required String fieldName,
  required List<int> fileBytes,
  required String fileName,
}) async {
  final request = http.MultipartRequest('POST', Uri.parse(_buildUrl(path)));
  // inject JWT
  request.files.add(http.MultipartFile.fromBytes(fieldName, fileBytes, filename: fileName));
  final streamed = await request.send();
  final response = await http.Response.fromStream(streamed);
  return parseResponse(response);
}

Permissions in Flutter

Because permissions live in the JWT app_metadata, the Flutter model parses them on sign-in and stores them on the User object:

void initFromJwt(String? token) {
  if (token == null) return;
  final parts = token.split('.');
  final payload = jsonDecode(
    utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
  );
  final meta = payload['app_metadata'] as Map<String, dynamic>? ?? {};
  orgId = meta['org_id'] as String?;
  orgPermissions = int.tryParse(meta['org_permissions'] as String? ?? '0') ?? 0;
  locationPermissions = (meta['location_permissions'] as Map<String, dynamic>? ?? {})
      .map((k, v) => MapEntry(k, int.tryParse(v as String) ?? 0));
}

bool hasOrgPermission(int required) => (orgPermissions & required) == required;

bool hasLocationPermission(String locationId, int required) {
  final p = locationPermissions[locationId] ?? 0;
  return (p & required) == required;
}

UI widgets use these directly to show/hide actions without a network round-trip:

if (user.hasOrgPermission(OrgPermissions.manageMembers))
  IconButton(icon: Icon(Icons.person_add), onPressed: _inviteMember),

Part 4: Flutter CI/CD

Workflow 1: Build Android AAB

The Android build pipeline handles the most sensitive part: keystore management. The keystore is GPG-encrypted and stored as a GitHub secret, decrypted at build time:

name: build-app

on:
  workflow_dispatch:
    inputs:
      flavor:
        type: choice
        options: [dev, prod]
      tag:
        description: "Release tag (e.g. v1.0.0)"
      release_name:
        description: "Release name"

jobs:
  build_aab:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-java@v3
        with:
          java-version: "17"
          distribution: "microsoft"

      - name: Decode and decrypt keystore
        run: |
          echo "${{ secrets.KEYSTORE_FILE_ENCRYPTED }}" > android/keystore.jks.asc
          gpg -d \
            --passphrase "${{ secrets.JKS_FILE_DECRYPTION_PASSWORD }}" \
            --batch \
            android/keystore.jks.asc > android/keystore.jks

      - name: Write key.properties
        run: echo "${{ secrets.KEY_PROPERTIES }}" > android/key.properties

      - uses: subosito/flutter-action@v2
        with:
          channel: stable

      - run: flutter pub get
      - run: flutter build appbundle --release --dart-define=FLAVOR=${{ inputs.flavor }}

      - uses: actions/upload-artifact@v4
        with:
          name: app-release.aab
          path: build/app/outputs/bundle/release/app-release.aab

  create_release:
    needs: build_aab
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: app-release.aab

      - name: Create GitHub release (draft)
        run: gh release create ${{ inputs.tag }} app-release.aab -t "${{ inputs.release_name }}" --draft
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The GPG pattern for keystores:

Never store a raw JKS in GitHub Secrets โ€” the 64 KB limit is tight and the binary doesn't survive encoding reliably. Instead:

# one-time local setup
gpg --symmetric --cipher-algo AES256 --batch --passphrase "YOUR_PASS" keystore.jks
base64 keystore.jks.gpg | pbcopy   # paste into KEYSTORE_FILE_ENCRYPTED secret

The --batch flag suppresses interactive prompts, which is required for CI. Builds are released as drafts โ€” a human reviews and publishes.

Workflow 2: Flutter Web to Vercel

name: deploy-web

on:
  workflow_dispatch:
    inputs:
      flavor:
        type: choice
        options: [dev, prod]

jobs:
  build_and_deploy:
    steps:
      - uses: subosito/flutter-action@v2

      - run: flutter pub get
      - run: flutter build web --release --dart-define=FLAVOR=${{ inputs.flavor }}

      - name: Copy Vercel config
        run: cp vercel.json build/web/vercel.json

      - name: Deploy to Vercel
        run: |
          FLAG=$([[ "${{ inputs.flavor }}" == "prod" ]] && echo "--prod" || echo "")
          vercel deploy build/web $FLAG --yes --token ${{ secrets.VERCEL_TOKEN }}
        env:
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

vercel.json is copied into the build output before deployment. Flutter web uses client-side routing, so all paths must route to index.html:

{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

Workflow 3: Publish to Play Store

Publish is a separate workflow from build. The build creates a GitHub release (draft); the publish workflow downloads the artifact from that release and submits to Play Store. This decouples "we have a build" from "we're releasing it" โ€” you can hold a build for QA, legal review, or a coordinated launch without touching the CI pipeline.

The Google service account credentials follow the same GPG pattern as the keystore.

name: publish-app
on: workflow_dispatch

jobs:
  publish_to_playstore:
    steps:
      - name: Download latest release AAB
        run: gh release download -p '*.aab'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Publish to Play Store
        uses: your-org/google-play-publish@main
        with:
          config_file: .release/android.json
          playstore_encrypted_file: ${{ secrets.G_CLIENT_SECRETS_FILE }}
          playstore_decryption_pwd: ${{ secrets.G_CLIENT_SECRETS_PWD }}

Putting It All Together: The Deploy Sequence

A full production release across both repos looks like this:

1. django-project:
   โ””โ”€โ”€ Trigger "Deploy to Prod" workflow with tag v1.4.2
       โ”œโ”€โ”€ Checkout tag
       โ”œโ”€โ”€ Bootstrap env (S3 cache hit โ†’ 15s)
       โ”œโ”€โ”€ CDK deploy (Lambda code + layer + infra)
       โ”œโ”€โ”€ Lambda invoke โ†’ migrate (same runtime, 5-min timeout)
       โ””โ”€โ”€ Lambda invoke โ†’ collectstatic

2. flutter-app:
   โ””โ”€โ”€ Trigger "build-app" with flavor=prod, tag=v1.4.2
       โ”œโ”€โ”€ Decrypt keystore
       โ”œโ”€โ”€ flutter build appbundle
       โ””โ”€โ”€ gh release create v1.4.2 --draft

   โ””โ”€โ”€ QA signs off โ†’ Trigger "publish-app"
       โ”œโ”€โ”€ gh release download *.aab
       โ””โ”€โ”€ Upload to Play Store (internal โ†’ review โ†’ production track)

   โ””โ”€โ”€ Trigger "deploy-web" with flavor=prod
       โ”œโ”€โ”€ flutter build web
       โ””โ”€โ”€ vercel deploy --prod

Total backend deploy time: ~8 minutes (CDK diff + Lambda update). Flutter build time: ~5 minutes (no Java/Gradle cache issues with a clean runner).


What Makes This Production-Grade

A few things that distinguish this from a tutorial setup:

No secrets in code or CI logs. Supabase keys are in SSM Parameter Store, fetched by the Lambda at runtime. The CI pipeline only holds AWS credentials โ€” everything else is resolved by the Lambda itself.

Migrations run in the deployment environment. Using Lambda invoke means migrations use the exact same DB connection, Python version, and environment variables as the production handler. No "it passed locally" surprises.

Zero-trust permissions. Every API request is authenticated via the JWKS endpoint. Permissions are verified from the JWT payload. The database is never queried for access control on the hot path.

Draft releases. Both Android and backend deploys require a human step before going live. Automation handles building and deploying; humans control shipping.

Dependency caching that actually works. The S3-based env artifact means CDK deploys are fast after the first run, even though GitHub Actions has no persistent cache across runs without explicit setup.


Common Pitfalls

Stale JWT permissions. If you update app_metadata without revoking sessions, the client holds a valid JWT with old permissions until it expires. Always revoke sessions after any permission change.

HS256 vs ES256 confusion.Supabase dashboard shows the JWT secret for HS256. If you switch to JWKS (ES256), the JWT secret is irrelevant โ€” don't use it on the backend. Use the JWKS URL from Dashboard โ†’ Project Settings โ†’ API.

Lambda layer binary compatibility. If you pip install without the --platformflags, packages compile against your CI runner's glibc, not Lambda's. The --only-binary=:all: --platform=manylinux2014_x86_64 flags are non-negotiable.

Missing Supabase redirect URLs. Mobile deep links and web redirect URLs must be listed in the Supabase dashboard. Forgetting yourapp:// causes password reset and magic link flows to break silently on device.

workflow_dispatch inputs in bash. The [[ ]] syntax in the Vercel deploy step requires bash. GitHub Actions run: defaults to bash on ubuntu-latest, but explicitly adding shell: bash makes the intent clear.


This stack has been running in production for several months. The architecture decisions โ€” JWT-embedded permissions, Lambda-invoked migrations, S3-cached build environments, and separated build/publish workflows โ€” each solve a specific real-world problem. Start with the CI workflow and Supabase auth, then layer in CDK and Flutter BLoC as the app grows.