CI/CD for Flutter with Azure DevOps: From Commit to Store
A working Azure Pipelines setup for Flutter — analyze, test, build signed Android and iOS releases, and ship them without touching a laptop.

For years my release process was: bump the version, run flutter build on my laptop, drag the file into the store console, and hope I’d remembered everything. It works until it doesn’t — the wrong keystore, a forgotten build number, a debug flag left on.
Over the last year I’ve moved every project I lead onto automated pipelines, mostly with Azure DevOps. This post is the setup I use, step by step. The same structure translates easily to GitHub Actions or Codemagic.
What the pipeline should do
On every pull request:
- Install a pinned Flutter version.
- Run
flutter analyzeand the formatter check. - Run unit and widget tests.
On every merge to main, additionally:
- Build a signed Android App Bundle.
- Build a signed iOS IPA.
- Publish both as artifacts and push them to internal testing tracks.
The rule behind all of it: a human should never build a release on their own machine. If it can only be built on one laptop, it’s not a process — it’s a risk.
Step 1: Pin and install Flutter
Pin the exact Flutter version the team uses. Different Flutter versions produce different builds, and “works on my machine” often means “my machine has a different Flutter”.
I keep it as a pipeline variable and clone that tag directly — no marketplace extension required:
variables:
FLUTTER_VERSION: '3.35.0'
steps:
- script: |
git clone https://github.com/flutter/flutter.git --depth 1 -b $(FLUTTER_VERSION) $(Agent.ToolsDirectory)/flutter
echo "##vso[task.prependpath]$(Agent.ToolsDirectory)/flutter/bin"
displayName: Install Flutter $(FLUTTER_VERSION)
- script: flutter --version && flutter pub get
displayName: Get dependencies
The ##vso[task.prependpath] logging command adds Flutter to PATH for all subsequent steps.
Step 2: Quality gates on every PR
- script: dart format --output=none --set-exit-if-changed .
displayName: Check formatting
- script: flutter analyze
displayName: Analyze
- script: flutter test --coverage
displayName: Run tests
Then enable a branch policy on main that requires this pipeline to pass before a pull request can be merged. That turns the checks from a suggestion into a rule.
Step 3: Sign Android builds with secure files
Never commit your keystore or its passwords. In Azure DevOps:
- Upload the
.jksfile to Pipelines → Library → Secure files. - Create a variable group with
KEYSTORE_PASSWORD,KEY_ALIASandKEY_PASSWORD, marked as secret.
In the pipeline, download the keystore and write the key.properties file your build.gradle reads:
- task: DownloadSecureFile@1
name: keystore
inputs:
secureFile: upload-keystore.jks
- script: |
cat > android/key.properties <<EOF
storeFile=$(keystore.secureFilePath)
storePassword=$(KEYSTORE_PASSWORD)
keyAlias=$(KEY_ALIAS)
keyPassword=$(KEY_PASSWORD)
EOF
displayName: Configure signing
- script: |
flutter build appbundle --release \
--build-number=$(Build.BuildId) \
--dart-define=ENV=production
displayName: Build Android App Bundle
Step 4: Build numbers you never think about
Both stores reject an upload with a build number they’ve already seen. Using $(Build.BuildId) (a number that only ever increases) as --build-number means you never bump it by hand again. The human-readable version (1.4.0) stays in pubspec.yaml and changes only when you mean it to.
Step 5: iOS on a macOS agent
iOS builds need a Mac. Microsoft-hosted macos-latest agents work well. Upload your distribution certificate (.p12) and provisioning profile as secure files, then install them with the built-in tasks:
- job: ios
pool:
vmImage: macos-latest
steps:
- task: InstallAppleCertificate@2
inputs:
certSecureFile: distribution.p12
certPwd: $(P12_PASSWORD)
- task: InstallAppleProvisioningProfile@1
inputs:
provProfileSecureFile: AppStore.mobileprovision
# ...install Flutter as in step 1...
- script: |
flutter build ipa --release \
--build-number=$(Build.BuildId) \
--export-options-plist=ios/ExportOptions.plist
displayName: Build IPA
The ExportOptions.plist tells Xcode which signing method and profile to use. Commit it to the repo — it contains no secrets.
Step 6: Publish artifacts and ship to testers
- task: PublishPipelineArtifact@1
inputs:
targetPath: build/app/outputs/bundle/release
artifact: android-release
From there, you can upload automatically:
- Google Play: the Google Play extension for Azure DevOps, with a service account JSON, can publish the bundle to the internal testing track.
- App Store: the App Store extension, or fastlane
pilot, can upload the IPA to TestFlight using an App Store Connect API key.
I push every main build to internal testing automatically, and promote to production manually from the store consoles. Automation gets the build to testers; a human still decides when real users get it.
Step 7: Environments with dart-define
Different API URLs and keys for development, staging and production go in via --dart-define (or --dart-define-from-file with a JSON file per environment), read in Dart with String.fromEnvironment. The pipeline decides the environment; the code never hard-codes it.
Things that tripped me up
- Caching the pub cache saves a surprising amount of time. The
Cache@2task keyed onpubspec.lockcut minutes off each run. - CocoaPods versions on hosted macOS agents change over time. If iOS builds break suddenly with no code change, check the agent image release notes first.
- Secrets in logs. Azure masks secret variables, but not values derived from them. Don’t
echoanything built from a secret. - Long-running pipelines get ignored. Keep the PR pipeline under ten minutes; move slow jobs (iOS builds, E2E tests) to the
mainpipeline or run them in parallel.
Takeaways
- Pin your Flutter version, and install it in the pipeline.
- Gate every PR on format, analyze and tests with a branch policy.
- Store keystores, certificates and passwords as secure files and secret variables.
- Use the pipeline’s build ID as your build number.
- Automate delivery to testers; keep the production release a deliberate human step.
Once it’s set up, releasing becomes boring — and boring releases are exactly what you want. If you’re setting up CI/CD for a Flutter app and want a hand, reach out.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).