Cover image for Flutter, a wonderful journey from 0 to app stores - CI featuring Codemagic and Fastlane
Stack Labs

Flutter, a wonderful journey from 0 to app stores - CI featuring Codemagic and Fastlane

pommedouze profile image Λ\: Olivier Revial ãƒģUpdated on ãƒģ19 min read

So you just started using Flutter and already love its wonderful and numerous widgets, but as your project is being serious you're wondering how to sign your Android & iOS apps and how to deploy them to both stores ? Oh, and as a lazy good developer, you're obvisouly wondering how to automate your builds and deployments to reduce manual actions (and errors) and free up some of your precious time to code your beautiful app ?

Well, me too. At first I thought it would be very simple and I'd just have to configure the awesome Codemagic, use its 500 free minutes per month... and relax 🛁

After all, it's supposed to be magic 🧙 !

In this article I will explain step-by-step how to easily automate app building and beta deployment to Android & iOS stores.


  • I am not claiming to have the best approach of all, but I needed a reminder of all the steps needed and I thought it could benefit others... tell me if you have better ways to do it or if I can improve my solution !
  • In this article we will only explore how to deploy to testing tracks (beta track for iOS and internal tests for Android), but the process should be pretty much the same for other tracks.

The various chapters we will detail are the following:

  1. đŸ‘Ĩ Prerequisites: all the accounts and things you need before setting up the CI
  2. 🧙‍♂ī¸ Codemagic setup: setup of the CI workflow(s)
  3. 🤖 Android setup
  4. 🍏📱 iOS setup

📁 You can find my demo repository on Github.


Hopefully you are reading this article before you actually need the CI, because asking all people in the company to create the various accounts may take some time.

đŸ‘Ĩ Accounts

In order to connect to different platforms through Codemagic you will need a few accounts and keys to be setup before you start implementing actual CI pipeline.

In particular you need the following accounts and keys:


Once you have your personal accounts for both Apple Connect and Google Play Console, you should take some time to create the application page for both stores as you won't be able to deploy Android or iOS test apps before you filled a minimum information:

  • Google Play Console : create a new application in the console.
    • Make sure you enter a correct package name, it's definitive !
  • Google Play Console : fill-in basic information. Start at app's dashboard and follow the guide to fill all required steps (there are a lot of steps but it's pretty simple)
    • You will need to upload at least an app icon (make sure you respect appropriate format), a Play Store commercial banner and 2 screenshots of the app before you can upload your first APK (yes, even for internal tests).
  • Apple Developer identifiers : create a new identifier of type App IDs and follow the steps.
    • Note generated bundleId and App team id, you will need them later.
    • Make sure you enter a correct bundle id, it's definitive !
  • Appstore Connect : create a new application in the console. You will need to link your new app to a bundleId... the one you created in previous step 🤘
  • Appstore Connect : fill-in basic information.

🧙‍♂ī¸ Codemagic setup

Codemagic use multiple files to do its "magic":

Let's start right away with Codemagic main file, codemagic.yaml:

    name: Internal deployment
    max_build_duration: 90
        BUILD_NAME: 1.2.3
        GOOGLE_PLAY_STORE_JSON_BASE64: Encrypted(var)
        ANDROID_KEYSTORE: Encrypted(var)
        ANDROID_KEYSTORE_PASSWORD: Encrypted(var)
        ANDROID_KEY_ALIAS: Encrypted(var)
        ANDROID_KEY_PASSWORD: Encrypted(var)
        IOS_APP_ID: com.company.somename
        FASTLANE_EMAIL: apple-technical@your-company.com
        FASTLANE_PASSWORD: Encrypted(var)
        MATCH_PASSWORD: Encrypted(var)
        SSH_KEY_FOR_FASTLANE_MATCH_BASE64: Encrypted(var)
      flutter: stable
      xcode: latest
      cocoapods: default
        - $HOME/Library/Caches/CocoaPods
        - $HOME/.gradle/caches
        - $FLUTTER_ROOT/.pub-cache
        - tag
      - name: Post clone setup for Android & iOS
        script: |
          #!/usr/bin/env sh
            /bin/sh $FCI_BUILD_DIR/codemagic/post-clone.sh all
      - name: Build & Deploy Android app
        script: |
          cd $FCI_BUILD_DIR/android

          bundle exec fastlane supply init --track internal --package_name com.company.somename
      - name: Build & Deploy iOS app
        script: |
          cd $FCI_BUILD_DIR/ios

          bundle exec fastlane ios beta
      - build/**/outputs/**/*.apk
      - build/**/outputs/**/*.aab
      - build/ios/ipa/*.ipa
      - /tmp/xcodebuild_logs/*.log
      - flutter_drive.log
        channel: '#flutter-codemagic-demo'
        notify_on_build_start: false
Enter fullscreen mode Exit fullscreen mode

If you're familiar with Gitlab CI or YAML in general, you shouldn't be too shocked reading this file. Let's describe the behaviour of your one and only workflow, internal-deployment:

  • đŸ”Ģ max_build_duration: a timeout duration, because sometimes Codemagic/Android/iOS builds get stuck.
  • ✓ environment: all environment variables that will be used by our scripts. More on this in Android and iOS setup. Also defines the versions of Flutter, Xcode and Cocoapods that Codemagic should use. It is good practice to use latest stable versions.
  • 🏎 cache: files that should be cached and reused across multiple builds to save some precious build minutes.
  • 🔘 triggering: how the build should be triggered. I decided that mine should be triggered every time I release a new tag in my Gitlab project. Note that I had to add a Gitlab webhook in order to automatically trigger Codemagic on tag creation.
  • â–ļī¸ scripts: the core of our workflow: what we should do when this Codemagic workflow is triggered. As you can see here we only delegate the work to Fastlane for both Android and iOS, as we will see in a minute.
  • đŸ“Ļ artifacts: the artifacts we want to publish on Codemagic. Not solely needed as artifacts will be published to iOS and Android consoles but I like to keep them here in case the build fails and I need to debug.
  • 📩 publishing: I like to be notified when my build fails or succeeds so I configured a Slack notification here.

The first script called by our worflow is a "post-clone" script, let's see this codemagic/post-clone.sh file:

#!/usr/bin/env sh

set -e # exit on first failed commandset

installGems() {
  echo "Installing gems..."

  bundle install
  bundle update fastlane
  bundle update signet

androidSteps() {
  echo "========================================"
  echo "|       Android post-clone steps       |"
  echo "========================================"

  # set up key.properties
  echo $ANDROID_KEYSTORE | base64 --decode > /tmp/keystore.keystore
  cat >> "$FCI_BUILD_DIR/android/key.properties" <<EOF

  # set up local properties
  echo "flutter.sdk=$HOME/programs/flutter" > "$FCI_BUILD_DIR/android/local.properties"

  echo "--- Generate Google Service key for Android"

  cd $FCI_BUILD_DIR/android

iosSteps() {
  echo "========================================"
  echo "|         iOS post-clone steps         |"
  echo "========================================"

  echo "--- Generate SSH key for Gitlab access from Fastlane Match"
  echo $SSH_KEY_FOR_FASTLANE_MATCH_BASE64 | base64 --decode > /tmp/bkey

  # adding custom ssh key to access private repository
  chmod 600 /tmp/bkey
  cp /tmp/bkey ~/.ssh/bkey
  ssh-add ~/.ssh/bkey

  cd $FCI_BUILD_DIR/ios

Enter fullscreen mode Exit fullscreen mode

This script is pretty boring but what it does is install a bunch of gems and setup some files that iOS and Android will need for building, signing and publishing the apps.

As you can see there is a bundle install that will use Android Gemfile and iOS Gemfile to automatically install required gems:

Android Gemfile

source "https://rubygems.org"

gem "fastlane"
Enter fullscreen mode Exit fullscreen mode

iOS Gemfile

source "https://rubygems.org"

gem "fastlane"
gem "cocoapods"
Enter fullscreen mode Exit fullscreen mode

Let's now see Android build script in android/fastlane/Fastfile file:


platform :android do
  desc "Deploy a new internal build to Google Play"
  lane :internal do
    Dir.chdir "../.." do
      sh("flutter", "packages", "get")
      sh("flutter", "clean")
      sh("flutter", "build", "appbundle", "--build-number=#{ENV["PROJECT_BUILD_NUMBER"]}", "--build-name=#{ENV["BUILD_NAME"]}")
        track: 'internal',
        aab: '../build/app/outputs/bundle/release/app-release.aab'
Enter fullscreen mode Exit fullscreen mode

This script basically uses Fastlane to build and deploy the app to Google Play Console. First it defines a "lane", which is just Fastlane way of defining a "worflow". Then we use shell to initialize and build our flutter app, by passing it build name and number from environment variables.

Once the signed application is ready, we use Fastlane once again to publish this app to the internal Android track through upload_to_play_store task.

Pretty simple right ? 🎉

Let's now see the equivalent for iOS:


platform :ios do
  before_all do
      # Create a local keychain that will later store iOS profiles and certificates
      if is_ci?
          puts "This is CI run. Setting custom keychain."
              name: 'Temp.codemagic_keychain',
              password: 'Temp.codemagic_keychain_password',
              default_keychain: true,
              unlock: true,
              timeout: 3600,

  desc "Push a new beta build to TestFlight"
  lane :beta do
    # Synchronize profiles & certificates from Git repo using Match
        type: "appstore",
        readonly: is_ci,
        keychain_name: 'Temp.codemagic_keychain',
        keychain_password: 'Temp.codemagic_keychain_password'
    # Disable automatic code signing as we will use custom signing method later on
      use_automatic_signing: false
    # Update Xcode provisioning profile with the one we got from Git repo using Match
      # https://github.com/fastlane/fastlane/issues/15926
      profile: ENV["sigh_#{ENV["IOS_APP_ID"]}_appstore_profile-path"],
      build_configuration: "Release",
      code_signing_identity: "iPhone Distribution",
      xcodeproj: "Runner.xcodeproj",
    # Replace version number with Codemagic build number
      path: "Runner/Info.plist",
      key: "CFBundleVersion",
    # Replace version name with our semver version
      path: "Runner/Info.plist",
      key: "CFBundleShortVersionString",
      value: ENV["BUILD_NAME"]
    # Run a first Flutter build with code signing disabled
    Dir.chdir "../.." do
      sh("flutter", "packages", "get")
      sh("flutter", "clean")
      sh("flutter", "build", "ios", "--release", "--no-codesign")
    # Run a second Flutter build with custom code signing
        workspace: "Runner.xcworkspace",
        scheme: "Runner",
        configuration: "Release",
        xcargs: "-allowProvisioningUpdates",
        export_options: {
            signingStyle: "manual",
            method: "app-store",
            provisioningProfiles: {
                "#{ENV["IOS_APP_ID"]}": "match AppStore #{ENV["IOS_APP_ID"]}"
    # Upload our build to TestFlight (Beta track)
        skip_waiting_for_build_processing: true,
        apple_id: "123456789"
Enter fullscreen mode Exit fullscreen mode

Alright, let's decrypt this file step-by-step:

  1. before_all : Before all steps run we create a local keychain that will later store iOS profile and certificates for app signting. This keychain will only live the time of the Codemagic build so we don't really care what the password is.
  2. match : We start the beta lane with profile and certificates synchronization. Codemagic configuration for iOS deployment can be rather complicated, in order to avoid this complexity by handling certificates and provisioning profiles ourselves, we use Fastlane and match for code signing. Here we basically tell match to retrieve all stored profiles and certificates from the repo we stored them previously, and store them into the keychain we created in the previous step. We tell match to get things for appstore type, i.e. release profile and certificates. We will see later on in iOS setup how to initialize match to create and store profile and certificates.
  3. update_code_signing_settings : We disable automatic code signing because we want to handle it manually using match-retrieved profile/certificates from previous step
  4. update_project_provisioning : This step is essential as it tells Xcode to use our generated profile and certificates instead of its default (automatic) signing files. We tell it to use match retrieved provisioning profile instead.
  5. set_info_plist_value : We replace Xcode version values with the correct version:
    • PROJECT_BUILD_NUMBER is the Codemagic global build number (global to your application, no matter which workflow you run). Useful to deploy the same version multiple times and from different workflows.
    • BUILD_NAME is the actual version of our project, in semver terminology. Note that it is up to you to handle the bump of this variable on each of your app's releases. In my case I use Gitlab pipeine with a custom script to automatically bump BUILD_NAME environment variable inside codemagic.yaml and commit this as a release commit.
  6. flutter build ios --release --no-codesign : Flutter setup with a full packages/clean and build process, but with codesign disabled. As stated in Flutter official CI/CD documentation, we need to initialize our project wiht a first Flutter build before we can actually run the signing build step with Fastlane (see next step)
  7. build_app : We use Fastlane once again to build a signed version of our iOS application. Of course we tell it to use the provisioning profile we retrieved in previous step with match.
  8. upload_to_testflight : We can finally use Fastlane upload_to_testflight task to automatically push our new .ipa application to Apple TestFlight (for beta testing). The two parameters are set to avoid waiting for Apple build processing to end Codemagic build. It means that you are not 100% sure that your build has correctly been received (and is valid) by Apple but in the other hand it has 2 great advantages:
    • It consumes less Codemagic free minutes waiting for an external response that is not really part of the build itself
    • It is the only way of only using an Application Specific Password to push your app. Otherwise you would need 2FA with a short-lived session token, and you don't want that on your CI. Really, believe me 🙄

🤖 Android setup

Codemagic configuration for Android deployment is pretty straightforward and is rather well described in code signing documentations of both Flutter and Codemagic

You will need to create a few things:

  1. Create a service account key to interact with Google Play Console
  2. Create a keystore for Android app signing
  3. Configure Android project to use code signing for release mode, with appropriate keystore
  4. Locally generate a signed Android application and upload this first release to Google Play Console

1. Google Play service account key

Let's start by creating the service account key.
These steps are very well described in Codemagic documentation (in Google Play section).

This variable is going to be translated into Android google-play-store.json by our codemagic/post-clone.sh script:

Enter fullscreen mode Exit fullscreen mode

...which will used by Fastlane further down the road when deploying our application to Google Play Console.

⚠ī¸ Only the app owner of Google Play Console project is able to create a service account key, make sure you ask this person to create (and send you) the JSON key !

We now need to create a file named android/fastlane/Appfile to give Fastslane our Google Play service key:

Enter fullscreen mode Exit fullscreen mode

Of course, you should put your Android package name here (lowercase letters and underscores only !)

ℹī¸ the actual JSON key will be populated later on by post-clone.sh script

2. Keystore

Generate a keystore for current project:

keytool -genkey -v -keystore your_app.jks -alias your_app_key -keyalg RSA -keysize 2048 -validity 10000 -storetype JKS
Enter fullscreen mode Exit fullscreen mode

You will prompted for a password.

⚠ī¸ Don't forget to securely store the password and share it with your team if they need to sign the app using the keystore (e.g. for CI builds). 🔐

ℹī¸ -storetype JKS option is required for Java 9+ otherwise you will not be prompted for alias key/password and the keystore will not be compatible for code signing anyway...

Now that you have an Android keystore, you need to encode your keystore to base64.

base64 -i your_app.jks
Enter fullscreen mode Exit fullscreen mode

3. Project configuration

Now that we have created util stuff needed to sign and publish our app to Google Play, we only need to tweak our configuration to reference info.

First open codemagic.yaml and define the following variables:

  • GOOGLE_PLAY_STORE_JSON_BASE64 : [Codemagic-encrypted] - base64 encoded version of JSON key downloaded from Google Play Console in step 1 above.
  • ANDROID_KEYSTORE : [Codemagic-encrypted] - base64 encoded version of the keystore generated in step 2 above.
  • ANDROID_KEYSTORE_PASSWORD : [Codemagic-encrypted] - password of the generated store
  • ANDROID_KEY_ALIAS : [Codemagic-encrypted] - alias of the generated key
  • ANDROID_KEY_PASSWORD : [Codemagic-encrypted] - password of the generated key alias

⚠ī¸ Variables that are marked as [Codemagic-encrypted] must be encrypted using Codemagic user interface.
Go to any project > configuration wheel > Encrypt environment variable and paste the variable you want to encrypt, and copy it back to codemagic.yaml.
Keep the Encrypted(VAR) around your variable !

These variables are going to be translated into Android key.properties by our codemagic/post-clone.sh script:

echo $ANDROID_KEYSTORE | base64 --decode > /tmp/keystore.keystore
cat >> "$FCI_BUILD_DIR/android/key.properties" <<EOF
Enter fullscreen mode Exit fullscreen mode

Finally, we can configure our android/app/build.gradle to sign our app using provided keystore. First add these lines above android tag:

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
Enter fullscreen mode Exit fullscreen mode

These lines tell Android to read keystore properties from our specific file. We can now add signing configs to the build (replace existing lines with these new ones):

signingConfigs {
    release {
        keyAlias keystoreProperties['keyAlias']
        keyPassword keystoreProperties['keyPassword']
        storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
        storePassword keystoreProperties['storePassword']

buildTypes {
    release {
        signingConfig signingConfigs.release
Enter fullscreen mode Exit fullscreen mode

You're ready to sign your Android app !

4. Signed release & first upload

In order to push automated releases of your Android app, you need to create a first release manually on the Google Play Console interface and upload a first signed bundle/apk.

You can follow these steps:

  1. Add a file named /android/key.properties with the following content:
Enter fullscreen mode Exit fullscreen mode

Obvisouly you need to replace the values of each property with the actual value you generated (same values as you set for corresponding environment variable in Project Configuration section).

  1. Make sure /android/key.properties is excluded in your /android/.gitignore. Flutter does this by default when creating the app, you should see the following line:
Enter fullscreen mode Exit fullscreen mode

If you don't have this line, obviously add it, it is crucial that you do not push secrets to your distant Git repository !

  1. Build and package the application with signing enabled (release mode):
flutter build appbundle --build-number 1 --build-name 0.0.1 --release
Enter fullscreen mode Exit fullscreen mode

Once the signed application is ready, it's time to upload it to Google Play Console: create a new release under "Internal tests" and drop your appbundle there, you should be able to continue with your brand new release.

Once your first release is accessible for internal tests, you can now upload automated releases through CI ! Yeah 🎉

⚠ī¸ Make sure you filled all necessary information on Google Play Console for internal tests (Dashboard will tell you what steps you need to complete first).

🍏📱 iOS setup

Codemagic configuration for iOS deployment can be rather complicated.
To avoid handling certificates and provisioning profiles ourselves, we'll be using Fastlane and match for code signing, as explained in Prerequisites.

First make sure you use latest Fastlane version:

sudo gem install fastlane
Enter fullscreen mode Exit fullscreen mode

Also create an empty Git repository called certificates somewhere in your Gitlab/Github/Bitbucket. We can no run a few commands to setup codesigning for iOS so make sure you are in ios subdirectory:

cd ios/
Enter fullscreen mode Exit fullscreen mode

Time to init match to point to our certificate repository:

match init
> Select git
> URL of the Git repo:
Enter fullscreen mode Exit fullscreen mode

This should generate a Matchfile in .fastlane/Matchfile with the following content:



type("development") # The default type, can be: appstore, adhoc, enterprise or development

# app_identifier(["tools.fastlane.app", "tools.fastlane.app2"])
# username("user@fastlane.tools") # Your Apple Developer Portal username

# For all available options run `fastlane match --help`
# Remove the # in the beginning of the line to enable the other options

# The docs are available on https://docs.fastlane.tools/actions/match
Enter fullscreen mode Exit fullscreen mode

⚠ī¸ Use a SSH url for the repository (rather than the HTTPS url) as we will use an SSH key later to enable Codemagic fetching our repository.

Now we have to create ios/fatslane/Appfile to fill out a few needed information for Fastlane (and match too):

app_identifier("com.company.awesomeApp") # The bundle identifier of your app
apple_id("apple-technical@your-company.com") # Your Apple email address for the technical account

team_id("123ABC1DE") # Developer Portal Team ID
itc_team_id("123456789") # App Store Connect Team ID
Enter fullscreen mode Exit fullscreen mode

ℹī¸ To easily output these variables you can run fastlane produce and you should get all these variables:

Once all variables are set, we can use match to create new certificates and profiles.
Please make sure that the Apple account you set in apple_id above has "App manager" permissions and not just "Developer" or you won't have permissions to create certificates and profiles.

Let's run the command to generate everything:

fastlane match appstore
Enter fullscreen mode Exit fullscreen mode

Enter password for apple-technical@your-company.com.

If the command executed successfully you should now have new, generated certificates and [profiles]https://developer.apple.com/account/resources/profiles/list).
Also these certificates should now be stored encrypted into your Git certificates repository.

⚠ī¸ Don't forget to securely store ios keychain and match passwords and share them with your team if they need to regenerate certificates ! 🔐

Now that you have generated profile and certificate, it's time to complete Codemagic workflow by setting all environment variables such as Fastlane environment variables:

  • BUILD_NAME : The version that you wish to deploy, in semantic versioning notation (e.g. 1.2.3, or more generally X.Y.Z)
  • IOS_APP_ID : the app identifier as set in Certificates, Identifiers & Profiles.
    • Example: com.company.awesomeApp
  • APPLE_DEVELOPER_TEAM_ID : Apple Developer Team Id (e.g. 123ABC4DE)
  • FASTLANE_EMAIL : Apple Developer account email (e.g. apple-technical@your-company.com)
  • FASTLANE_PASSWORD : Apple Developer account password
  • MATCH_PASSWORD : [Codemagic-encrypted] - Password used when generating fastlane match appstore above using match.
  • SSH_KEY_FOR_FASTLANE_MATCH_BASE64 : [Codemagic-encrypted] - Base64 encoded private SSH key of Codemagic user for Gitlab. You can do the following to generate SSH keys :
    • Generate a new SSH key pair : ssh-keygen -t ed25519 -C "SSH key description"
    • Copy public key and add it under Gitlab's SSH keys for Codemagic user.
    • Copy private key and encrypt it using Codemagic (see note below)
    • Delete SSH key from your local computer
    • If you ever need to see the key... you can't, just revoke the old one and generate a new pair !
  • FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD : [Codemagic-encrypted] - Apple Application specific password as described in Fastlane documentation. You can follow these steps:
    • Go to Apple manage account page with your Apple account corresponding to FASTLANE_EMAIL.
    • Generate a new application specific password
    • Encrypt the password using Codemagic (see note below)

ℹī¸ You can run fastlane produce to output some of these information (app_identifier, team_id, itc_team_id).

⚠ī¸ Variables that are marked as [Codemagic-encrypted] must be encrypted using Codemagic user interface.
Go to any project > ⚙ī¸ > Encrypt environment variable and paste the variable you want to encrypt, and copy it back to codemagic.yaml.
Keep the Encrypted(VAR) around your variable !

iOS Project configuration

Just a few more things we need to setup:

  • In ios/Runner.xcodeproj/project.pbxproj, set all DEVELOPMENT_TEAM variables to your actual Apple Development team (must be the same as APPLE_DEVELOPER_TEAM_ID env var above). If development team variables do not appear in ios/Runner.xcodeproj/project.pbxproj you can setup manually by doing following steps:
    • Open Xcode
    • Select Runner in left pannel
    • In central panel select Targets > Runner
    • Open "Signing & Capabilities" tab
    • Under "All" sub-tab, disable automatic code siging and select your team from the appropriate field.

If no team is found you can also import a provisioning profile after downloading it from Apple profiles

  • In ios/Runner/Runner.entitlements (create the file if you don't have it), setup required Apple entitlements for your application.

    • If you don't use any entitlements, you should still declare it by leaving the file with an empty list:
      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
      <plist version="1.0">

Phew, you should be all set for iOS automated deployment 🎉


As you can see setting up CI/CD automation for Flutter projects to go from the ground up to deployed beta versions of both Android and iOS applications requires a bit of patience, but in the end once you understand the various components interacting with each other, it's almost magic ! 🧙‍♀ī¸

My advice : take the time to set up CI/CD when your Flutter project starts, you will be grateful to have it all automated when comes the mandatory rush of your project 🙂


Editor guide