CI/CD for Flutter: Auto-Deploy to Google Play with GitHub Actions
DevOps
7 min read
September 12, 2025

CI/CD for Flutter: Auto-Deploy to Google Play with GitHub Actions

Stop uploading AABs manually. Learn how to set up a GitHub Actions pipeline that builds, signs, and deploys your Flutter app to Google Play on every push to master.

Muhammad Nabi Rahmani

Muhammad Nabi Rahmani

Flutter Developer passionate about creating beautiful mobile experiences

CI/CD for Flutter: Auto-Deploy to Google Play with GitHub Actions

Every time you manually build an AAB, open the Play Console, upload the file, and click through the release wizard, you lose 20 minutes and introduce the risk of human error. Let's automate the entire process so pushing to master means deploying to production.

What We're Building

A GitHub Actions workflow that:

  1. Triggers on push to master (or manual dispatch)
  2. Decodes your upload keystore from a GitHub secret
  3. Builds a signed release AAB with flavor support
  4. Uploads the AAB directly to Google Play

No manual steps. Push code, grab coffee, app is live.

One-Time Setup

1. Google Play Service Account

You need a service account that GitHub Actions can use to upload to Play Console:

  1. Go to Google Cloud Console > IAM > Service Accounts
  2. Create a service account and download the JSON key
  3. In Play Console > Users and permissions, add the service account email with "Release Manager" role

2. Upload Keystore

Create your upload keystore if you haven't already, then base64-encode it for GitHub:

base64 -i /path/to/upload-keystore.jks | tr -d '\n' | pbcopy

This copies the encoded keystore to your clipboard. Paste it into a GitHub secret.

3. GitHub Secrets

Add these secrets to your repository (Settings > Secrets > Actions):

SecretValue
GOOGLE_PLAY_SERVICE_ACCOUNT_JSONRaw JSON from the service account key file
ANDROID_KEYSTORE_JKSBase64-encoded upload keystore
ANDROID_KEYSTORE_PASSWORDKeystore password
ANDROID_KEY_ALIASKey alias (usually upload)
ANDROID_KEY_PASSWORDKey password

4. The Workflow File

# .github/workflows/build-upload.yaml
name: Build & Upload to Google Play

on:
  push:
    branches: [master]
  workflow_dispatch:

jobs:
  build-android:
    runs-on: self-hosted  # or ubuntu-latest
    environment: prod

    steps:
      - uses: actions/checkout@v4

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

      # Decode keystore from secret
      - name: Decode Keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_JKS }}" | base64 -D > android/android_keystore.jks

      # Create key.properties
      - name: Create key.properties
        run: |
          cat > android/key.properties << EOF
          storeFile=${{ github.workspace }}/android/android_keystore.jks
          storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
          keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
          EOF

      - run: flutter pub get
      - run: flutter build appbundle --release --flavor prod

      # Upload to Google Play
      - uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.yourcompany.yourapp
          releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
          track: production

Versioning: Don't Forget the Build Number

Google Play rejects uploads if the versionCode hasn't increased. Always bump the +N part in pubspec.yaml before pushing:

version: 1.0.1+7  # +7 is the versionCode

You can automate this with a script that reads the current version and increments it, or simply remember to bump it as part of your release process.

Self-Hosted Runner Tips

If you're using a self-hosted macOS runner (for faster builds or iOS support too):

  • No spaces in the runner path. /Users/you/actions-runner works. /Users/you/My Folder/actions-runner breaks everything.
  • Start the runner with ./run.sh and wait for "Listening for Jobs"
  • Don't run multiple runner sessions simultaneously
  • Keep runner files out of git (add actions-runner/ to .gitignore)

Track Options

TrackUse Case
internalTeam testing, fastest review
alphaClosed testing with testers
betaOpen testing before launch
productionLive release to all users

Start with internal to validate your pipeline works, then switch to production when you're confident.

Common Failures and Fixes

No logs / instant fail: The runner is offline, environment approval is pending, or the runner work folder is misconfigured.

"No such file or directory" with spaces: Your runner path contains spaces. Move it to a clean path.

"base64: invalid input": The ANDROID_KEYSTORE_JKS secret has wrong encoding. On macOS, the workflow needs base64 -D (not base64 -d).

Upload errors: The package name in the workflow doesn't match the Play Console app, or the track doesn't exist yet. Create the track in Play Console first.

Security Checklist

  • Never commit keystore files to source control
  • Never commit key.properties
  • Never commit runner configuration files
  • Use GitHub environment protection rules for production deployments
  • Rotate the service account key periodically

What This Gets You

  • Consistent builds: Same environment every time, no "works on my machine" issues
  • Faster releases: Push to master, deployment happens automatically
  • Audit trail: Every deployment is a GitHub Actions run you can inspect
  • Team collaboration: Anyone with merge access can deploy
  • Rollback: Git revert + push = automatic rollback deployment

Start Simple

Set up the workflow with track: internal first. Push to master, verify the AAB appears in Play Console's internal testing track. Once that works reliably, change to production.

The initial setup takes about an hour. After that, every release takes zero manual effort. The time you save compounds with every single deployment.

Share:

Keep Reading

More articles you might enjoy

© 2026 Mohammad Nabi RahmaniBuilt with Next.js & Tailwind