Automating Flutter Mobile Deployments to Google Play and App Store with Fastlane & GitHub Actions

Last week, I fell down the mobile deployment rabbit hole. Hard. As a web developer, I'm used to pushing code and having it just work in production. Deploying my Flutter app, Ledgerly, to Google Play and the App Store? That's a whole different universe of config files, certificates, and manual steps. Honestly, the sheer number of tweaks just to ship one app still shocks me. But I battled through it, automated the entire process, and now I'm sharing the robust pipeline that finally brought sanity back to my mobile releases.
Introduction
Let's be real: as web developers, we are spoiled. We push to main
Since I built my first mobile app, I discovered that for mobile development, it's another story. Close to say a horror story. Especially when I started building for iOS. Between Graddle & xCode config files and certificate signing, I didn't know where to start to create a seamless deployment pipeline, targeting the two platforms.
That's when I discovered Fastlane. The easiest way to build and release mobile apps, as they describe themselves.
It took me a couple of hours and a lot of failed workflow runs in Github Actions, but I finally built a robust pipeline that does exactly what I what.
So, here is my complete playbook on how to automate your Flutter deployments using Fastlane and GitHub Actions, even if you don't own a Mac. Buckle up, this is going to be a long one!
Action plan
This isn't a theoretical overview. It is a hands-on guide to the exact (more or less) pipeline running in production for Ledgerly. You can follow along, adapting it to your specific case.
Building iOS apps without a Mac: My take on building iOS apps strictly using GitHub Actions runners, saving you the cost of a dedicated Mac build server (or a new MacBook).
Setting up Fastlane: Before we touch any cloud consoles, we need to get Fastlane running locally.
Setting up to Google Play deployment: We'll start with Google. Setting up the Service Accounts, permissions, and the Fastlane configuration to handle those annoying version code conflicts between tracks.
Setting up App Store deployment: Then we'll see how to setup App Store Connect and how to address the "signing nightmare" using Fastlane Match to store certificates securely in Git.
Automating all of that with Github Actions: We'll cover how to setup the build environment for each platform and how to securely inject your secrets (keystores and certificates) into the pipeline so you never commit sensitive data.
The Result:
orgit bush and watch the magic operate.git tag -a
Building iOS apps without a Mac
The Problem: To build an iOS app, you need Xcode. To run Xcode, you need macOS. For a solo dev or a small team on Windows/Linux, buying a $2,000 MacBook just to act as a build server is a painful pill to swallow.
The Cloud Solution: GitHub Actions offers macos-latest runners. These are virtual machines with Xcode pre-installed. You effectively "rent" a Mac for the 15-20 minutes it takes to build your app.
The Trap: But wait, there's a trap, macOS build minutes on GitHub are expensive.
Standard Linux runners: 1x multiplier.
macOS runners: 10x multiplier.
If you run your entire CI pipeline (linting, unit tests, Android builds, iOS builds) on a macOS runner just because it's "easier" to have one workflow, you will burn through your free tier or budget in days.
My Advice: To make this viable, we must split our architecture:
Heavy Lifting on Linux: Run all unit tests, static analysis, and the Android build on linux based runners like ubuntu-latest. It's faster and significantly cheaper.
Sniper Shot on macOS: Create a specific, separate workflow only for the iOS compilation and signing steps, running on macos-latest.
This is how you can efficiently build for iOS without buying a mac, we will jump into the details in the following sections.
Setting up Fastlane
If you are coming from the JavaScript/NPM world, Fastlane lives in the Ruby ecosystem. This can be a bit tricky if you aren't used to it, so let's set it up the "right" way to avoid permission headaches later.
The environment
Fastlane requires Ruby.
Mac Users: macOS comes with a system Ruby, but I strongly recommend avoiding it. It requires sudorbenvrvm
Windows/Linux Users: You just need to install Ruby.
Windows: Use the RubyInstaller.
Linux:
(or your distro's equivalent).sudo apt-get install ruby-full
The Gemfile (Crucial for CI)
You could just run gem install fastlane
In a CI environment (like GitHub Actions), we want to ensure the exact same version of Fastlane is used every time. The best way to do this is with a Gemfilepackage.json
In the root of your Flutter project, create a file named Gemfile
source "[https://rubygems.org](https://rubygems.org)"
gem "fastlane"
Now install Fastlane with this command
# Run this in your project root
bundle install
Initialize Fastlane
Fastlane treats Android and iOS as separate entities. We need to initialize them separately within our Flutter project structure.
For Android:
cd android
bundle exec fastlane init
It will ask for your package name (e.g., com.yourdomain.yourapp
It might ask for a JSON secret file. You can skip this for now by pressing Enter; we will set it up in the next section.
For iOS:
cd ios
bundle exec fastlane init
Select Option 4: Manual setup. Since we are building a custom pipeline, we want full control over the Fastfile rather than letting Fastlane guess what we want. But you can explore what the different options do later or in the doc.
You should now have a fastlane folder inside both your android and ios directories. They all contain two files:
: where we will tell Fastlane about our appAppfile : where we will define our deployment steps (lanes) using Fastlane actions and some custom made functions.Fastfile
It's in those files that one side of the magic will happen.
Setting up to Google Play deployment
Deploying Android apps to Google play automatically requires setting up things into two different Consoles: Google Cloud and Play Console.
To automate uploads, you can't log in with your Gmail and 2FA. You need a Service Account that has permission to act on your behalf.
Step 1: Create the Service Account (Google Cloud)
This part happens in the Google Cloud Console, not the Play Console.
Open the Google Cloud Console and select the project linked to your app. If you don't have one yet, you can create one.
Navigate to IAM & Admin > Service Accounts.
Click + Create Service Account.
Name it:
(or something similar).fastlane-upload
Grant Access: This is critical. Assign the role Service Account User. You can skip the steps with the Principals or just add yourself there if you want to perform actions later as the service account we just created.
Generate Key: Once created, click the three dots on the service account row > Manage keys > Add Key > Create new key.
Select JSON.

STOP: A file will download to your computer. This is the literal key to your app's distribution. Do not lose it. Do not commit it to Git. By the way, Google advise against using service accounts keys and recommend using Workload Identity Federation instead, but that goes beyond the scope of this article. We will stick to the key we just downloaded and will add it later in our CI secrets.
Step 2: The Handshake (Play Console)
Now that we have a service user account, we have to introduce it to the Google Play Console.
Open the Google Play Console.
Go to Users and Permissions.
Click Invite New Users.
Paste the email address of the Service Account you just created (it looks like
).fastlane-upload@project-id.iam.gserviceaccount.comPermissions: Go to the "App Permissions" tab and add your app.
Ensure you check:
Release to production, exclude devices, and use Play App Signing
Release to testing tracks
Click Invite User.
Step 3: The Fastlane Configuration
Now, let's write the code to build and deploy the Android app using Fastlane.
The Appfile:
This is a simple configuration file where you tell Fastlane about your app. It just links the credentials to the package.
The Fastfile:
Here is where things get interesting. Android requires a unique Version Code (an integer) for every single upload. If you have Version Code 10 in your "Internal" track, you cannot upload Version Code 10 to "Beta." It will fail.
Managing this manually across Internal, Beta, and Production tracks is a nightmare. You inevitably end up guessing numbers.
Fastlane provides a lot of actions you can use in your Fastfile to perform different tasks. To solve the version code problem, I created a function get_next_build_numbergoogle_play_track_version_codes
Here is its full configuration:
With this setup, I never have to check pubspec.yaml for the version code again. Fastlane asks Google: "What's the latest version?" and automatically increments it.
Then we need to define two lanes for our deployments: one for the beta track and the other for the production track.
Let's start with the beta track:
What this code does is pretty much self explanatory.
We get the version code to use using our function
get_next_build_number.Then we build the app using sh (fastlane action for executing commands). There actions to build the app directly but I did use those since I needed to pass some custom arguments to the builder. I'm not sure yet if the Fastlane actions allow that but, could be interesting to check.
Upload the app to the Google Play beta track with a draft status. This means I will have to log into the Console and manually Release the app to the track, but you can set it to completed to automatically send the changes for review to Google. In fact there's a lot of things you can automate with this single action. I invite you to check the options in the documentation.
The last two steps depends on some environment variables that we will configure in our Github action. So don't worry if you don't see yet how authentication is handled.
Now the production track will look pretty much the same:
And that's it. Same steps as the beta track, except that we also read the version name (not version code) from the environment. This will be passed through from the CI.
So here is our final Fastfile for Android:
Setting up App Store deployment:
Now for the boss battle: Apple.
iOS automation is notoriously harder than Android. Why? Two reasons:
Two-Factor Authentication (2FA): You can't just log in with a username and password in a CI environment because Apple will ask for a 6-digit code.
Signing Certificates: Managing certificates and provisioning profiles across multiple developer machines and a CI server is usually a disaster.
We are going to solve both of these problems elegantly.
Step 1: Bypassing 2FA with API Keys
Instead of using a user account, we will use an App Store Connect API Key. This allows Fastlane to talk to Apple's servers without ever hitting a 2FA prompt.
Log in to App Store Connect.
Go to Users and Access > Integrations > App Store Connect API.
Click the + button to generate a new key.
Name:
(or similar).Fastlane CIAccess: Give it App Manager access.
Download the Key: Click "Download API Key.
IMPORTANT: You can only download this file (
) once. If you lose it, you have to revoke it and create a new one. Save it safely!.p8Note down the Issuer ID and Key ID from the page. You will need these for your CI secrets.
Step 2: Solving the Signing Nightmare (Fastlane Match)
If you've ever manually downloaded a .p12
How it works:
You create a private Git repo (e.g.,
).fastlane-certsYou run
locally from thefastlane match init folder in the root of your Flutter project.iosFastlane creates the certs, encrypts them, and pushes them to the repo.
Your CI server (GitHub Actions) pulls the repo, decrypts the certs using a passphrase, and installs them.
Setting it up:
Create a new, empty
repository on GitHub (e.g.,private ).my-app-certificatesIn your terminal (inside your `ios` folder), run
:fastlane match initSelect
as the storage mode.gitPaste the URL of your new private repo.
Generate the certs:
This will ask for a passphrase. This passphrase encrypts the certificates. Do not lose this. This will be another secret for our CI.fastlane match appstore.
Step 3: The Fastlane Configuration
Now, let's configure `ios/fastlane/Fastfile`.
The Appfile:
Just like Android, tell Fastlane about our app.
The Fastfile:
We need to do two things before we can build:
Create a Keychain: GitHub Actions runners are fresh VMs. They don't have a keychain to store certificates. We have to create a temporary one.
Sync Certificates: Use match to download and install our certs into that keychain.
Here is the setup block:
Now, let's define our lanes.
The Beta Lane (TestFlight): This lane authenticates with the API Key, checks TestFlight for the latest build number, increments it, builds the app, and uploads it.
The Production Lane (App Store): Just like Android, we want manual control over the version string for production releases.
And that's it for the config files! We now have a way to build and upload for both platforms using simple commands.
But we don't want to run these commands manually. We want GitHub to do it for us.
Automate with Github Actions
We have our lanes. We have our keys. Now we need a machine to run them.
This is where GitHub Actions shines. It allows us to trigger these Fastlane scripts automatically whenever we push code.
The Strategy:
Android: Run on ubuntu-latest (Linux). It's fast, cheap, and perfect for Android.
iOS: Run on macos-latest. Use this only for the iOS specific tasks (build & sign).
Step 1: Secrets Definition
Our pipeline needs access to sensitive files: the Android Keystore (.jks.p8
You cannot upload files directly to GitHub Secrets. You can only store strings.
The Solution: Convert your files to Base64 strings.
Run this in your terminal for each file you need to store:
On Mac/Linux:
# For the Android Keystore
base64 -i android/app/release-key.jks | pbcopy
# For the Google Play JSON
base64 -i android/fastlane/upload-keystore.json | pbcopy
On Windows (PowerShell):
Add them to GitHub:
Go to your Repository > Settings.
Secrets and variables > Actions.
Click New repository secret.
Add these secrets:
: The base64 string of your .jks file.ANDROID_KEYSTORE_BASE64 : The password of your .jks fileANDROID_KEYSTORE_PASSWORD : The alias of the key in your .jks fileANDROID_KEY_ALIAS : The password of the key in your .jks fileANDROID_KEY_PASSWORD : The base64 string of your service account JSON.PLAY_STORE_JSON_BASE64 : The API key from the Setting up App Store deployment step.APP_STORE_CONNECT_API_KEY_ID : The issuer ID from the Setting up App Store deployment step.APP_STORE_CONNECT_ISSUER_ID : The base64 string (or raw content) of your .p8 API key.APP_STORE_KEY_CONTENT : The passphrase you used to encrypt your Git repo with Fastlane Match.MATCH_PASSWORD : Can be anything, used locally on the runner.MATCH_KEYCHAIN_PASSWORD : Your GitHub Personal Access Token (base64 encoded) so Fastlane can read your private certs repo.MATCH_GIT_BASIC_AUTHORIZATION
To learn more about the origin of the Android keystore password, alias and key password, refer to the Flutter's documentation on Signing the app. These environment variables are referenced in the app build.gradleandroid/app/build.gradle
Now, let's have a look at the Github Actions workflows. To keep this article short (I'm already tired of typing ^^), I will be providing the full files for each track (production, beta) and platform (iOS, Android). Check the comments for explanations on what does what.
Step 2: The Android Workflow (Linux)
We use ubuntu-latest here to save money. Note the step where we recreate our sensitive files from the Base64 secrets.
Create .github/workflows/deploy-android-beta.yaml
I usually prefer having different workflows for each environment. Even if most of the steps are identical. So for production, create .github/workflows/deploy-android-production.yaml
Step 3: The iOS Workflow (macOS)
For iOS, we follow the same pattern: Setup the build environment, define our environment variables and call the Fastlane lane. This runs on the expensive macos-latest runners. We keep it lean.
Create .github/workflows/deploy-ios-beta.yaml
The production workflow is very similar except, it is triggered by a tag push and the version name is passed as an environment variable. Create .github/workflows/deploy-ios-production.yaml
Conclusion
And here you have it! All you have to do now is git push on any of the release/* branches or push at tag starting with the platform name to have a deployment triggered. How cools is that?
We have successfully:
Created service account users for Google and Apple.
Solved the signing nightmare with Fastlane Match.
Written scripts to automate the tedious build steps.
Built a pipeline that runs automatically on GitHub.
Now, shipping Ledgerly to my users is as simple as merging a pull request. No manual archiving, no certificate juggling, and most importantly I can finally focus on writing code again.
What about you now, how do you automate this steps in your Flutter project? I'll be happy to read your process, so feel free to start the discussion below.
Related article

The Story of Building a Privacy-First Financial Future
Tired of choosing between financial tracking and data privacy? I built Ledgerly because that trade-off is unacceptable. This article details the Zero-Server architecture behind Ledgerly: a mobile app built entirely with Flutter and local SQLite that guarantees your transactions, debt, and budgets never leave your device.
- Date


