If you have ever shipped a Flutter app to the App Store, you know the pain.
Open Xcode. Wait for indexing. Archive. Wait again. Upload. Fill in release notes manually for every locale. Click through 15 screens. Hope nothing breaks.
Now imagine doing that every week. Or every time you fix a critical bug.
There is a better way. Fastlane lets you automate the entire iOS deployment process with a single terminal command. No Xcode GUI, no manual uploads, no copy-pasting release notes.
In this guide, I will walk you through a real Fastlane setup that I use to deploy my own Flutter apps. Not a toy example. A production config that handles API authentication, localized metadata, and App Store uploads.
Why Fastlane for Flutter iOS deployment
Flutter builds your app. Fastlane ships it.
Flutter's flutter build ipa gives you a binary. But getting that binary to the App Store with the right metadata, release notes, and screenshots is a whole separate job. That is where Fastlane comes in.
Here is what Fastlane handles for you:
- Authentication with App Store Connect (no 2FA prompts)
- Metadata management — descriptions, keywords, release notes for every locale
- Binary uploads — push your IPA directly to App Store Connect
- Version creation — create new app versions programmatically
- Submission — optionally submit for review automatically
All from one command in your terminal.
Prerequisites
Before we start, make sure you have:
-
A Flutter app that builds successfully with
flutter build ipa - An Apple Developer account with App Store Connect access
-
Ruby installed (macOS comes with it, or use
rbenv) - Fastlane installed:
gem install fastlane
Then initialize Fastlane in your Flutter project's ios/ directory:
cd ios
fastlane init
Choose option 4 (manual setup) when prompted. This creates the ios/fastlane/ directory with a Fastfile and Appfile.
Step 1 — Set up App Store Connect API key
The first thing you want to do is stop using your Apple ID to authenticate. Apple's 2FA will block your automation every time.
Instead, create an App Store Connect API key:
- Go to App Store Connect > Users and Access > Integrations > App Store Connect API
- Click the + button to generate a new key
- Give it a name like "Fastlane CI" and select the App Manager role
- Download the
.p8key file — you can only download it once
Now store these values as environment variables. Add them to your .zshrc, .bashrc, or CI secrets:
export FASTLANE_APP_STORE_CONNECT_API_KEY_ID="YOUR_KEY_ID"
export FASTLANE_APP_STORE_CONNECT_API_ISSUER_ID="YOUR_ISSUER_ID"
export FASTLANE_APP_STORE_CONNECT_API_KEY_PATH="/path/to/AuthKey.p8"
In your Fastfile, create a helper function that loads this key:
def load_api_key
app_store_connect_api_key(
key_id: ENV["FASTLANE_APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["FASTLANE_APP_STORE_CONNECT_API_ISSUER_ID"],
key_filepath: ENV["FASTLANE_APP_STORE_CONNECT_API_KEY_PATH"],
in_house: false,
)
end
Every lane calls load_api_key first. No passwords, no 2FA, no session tokens that expire.
Step 2 — Organize your metadata
Fastlane uses a folder structure to manage your App Store metadata. Each locale gets its own folder with text files for the different fields.
fastlane/metadata/
en-US/
release_notes.txt
description.txt
keywords.txt
fr-FR/
release_notes.txt
description.txt
de-DE/
release_notes.txt
...
You can bootstrap this structure by pulling your existing metadata from App Store Connect:
desc "Download metadata from App Store Connect"
lane :download_app_metadata do
load_api_key
deliver(
download_metadata: true,
download_screenshots: false,
force: true,
)
end
Run it with:
fastlane download_app_metadata
This downloads all your current descriptions, keywords, and release notes into the metadata/ folder.
Now you can edit them locally and push updates without touching App Store Connect.
(I tend to split screenshots and metadata into separate lanes, so I can update text without worrying about images, and vice versa.)
Handling localized release notes
If you support multiple languages, you need release notes for each locale. Here is a helper that loads them all, with a fallback to English:
def load_release_notes(metadata_path = nil)
metadata_path ||= File.join(__dir__, "metadata")
fallback = File.read(File.join(metadata_path, 'en-US/release_notes.txt'))
locales = %w[ar-SA ca cs da de-DE el en-AU en-CA en-GB en-US
es-ES es-MX fi fr-CA fr-FR he hi hr hu id it ja
ko ms nl-NL no pl pt-BR pt-PT ro ru sk sv th tr
uk vi zh-Hans zh-Hant]
locales.each_with_object({}) do |locale, hash|
file = File.join(metadata_path, locale, 'release_notes.txt')
hash[locale] = File.exist?(file) ? File.read(file) : fallback
end
end
This is important. If you forget a locale, Apple will reject your submission or show stale release notes. This helper makes sure every supported locale has content.
You can remove any locale you don't support, but keep the fallback to avoid submission failures.
Note:
You can create a translations script to automatically translate your release notes from English to all your other languages.
All Apple App Store locales by priority
Not all locales are equal. Some cover massive markets with high App Store spending. Others are nice-to-have. Here is a prioritization to help you decide what to translate first.
Tier 1 — Cover these first. These locales represent the largest App Store markets by revenue and downloads. Skipping any of them means leaving money on the table.
| Locale | Language | Why |
|---|---|---|
| en-US | English (US) | Largest App Store market by revenue |
| en-GB | English (UK) | Second-largest English-speaking market (optionnal as it's covered by en-US) |
| ja | Japanese | Japan is the #2 App Store market worldwide |
| zh-Hans | Chinese Simplified | Massive user base, high spending on in-app purchases |
| ko | Korean | South Korea has one of the highest ARPU |
| de-DE | German | Germany is the top European market |
| fr-FR | French (France) | Major European market and 2nd language impact on surrounding countries |
| es-ES | Spanish (Spain) | Gateway to all Spanish-speaking countries |
| pt-BR | Portuguese (Brazil) | Largest Latin American market |
| en-AU | English (Australia) | High ARPU English-speaking market |
Tier 2 — Add these next. Strong markets that will move the needle once Tier 1 is covered.
| Locale | Language | Why |
|---|---|---|
| zh-Hant | Chinese Traditional | Covers Taiwan and Hong Kong |
| it | Italian | Large European economy |
| nl-NL | Dutch | High smartphone penetration |
| sv | Swedish | High ARPU Nordic market |
| da | Danish | High ARPU Nordic market |
| no | Norwegian | High ARPU Nordic market |
| tr | Turkish | Large and growing mobile market |
| pl | Polish | Biggest Central European market |
| ar-SA | Arabic (Saudi Arabia) | High spending Gulf market |
| he | Hebrew | Israel is a strong tech market |
| en-CA | English (Canada) | High ARPU North American market |
| fr-CA | French (Canada) | Required for Quebec App Store visibility |
| es-MX | Spanish (Mexico) | Second-largest Latin American market |
| pt-PT | Portuguese (Portugal) | Completes Portuguese coverage |
Tier 3 — Nice to have. Smaller markets or languages where English metadata often performs just as well. Add them when you have the bandwidth.
| Locale | Language | why |
|---|---|---|
| ca | Catalan | |
| cs | Czech | |
| ru | Russian | Large user base but currently can't pay for apps |
| el | Greek | |
| fi | Finnish | |
| hi | Hindi | |
| hr | Croatian | |
| hu | Hungarian | |
| id | Indonesian | |
| ms | Malay | |
| ro | Romanian | |
| sk | Slovak | |
| th | Thai | |
| uk | Ukrainian | |
| vi | Vietnamese | can be used to impact US market |
A practical approach: start with Tier 1 using quality translations, cover Tier 2 with machine translation reviewed by a native speaker, and use English fallbacks for Tier 3 until your app gains traction in those markets.
Step 3 — Create the "release new version" lane
This lane creates a new app version on App Store Connect and pushes your metadata — without uploading a binary. This is useful when you want to update release notes, descriptions, or keywords independently.
desc "Create a new version and push metadata"
lane :release_new_version do |options|
load_api_key
produce(
app_identifier: "com.yourcompany.yourapp",
app_version: options[:version],
)
release_notes = load_release_notes('./metadata')
deliver(
app_version: options[:version],
skip_binary_upload: true,
force: true,
submit_for_review: false,
automatic_release: false,
metadata_path: "./fastlane/metadata",
release_notes: release_notes,
precheck_include_in_app_purchases: false,
skip_screenshots: true,
)
end
Run it:
fastlane release_new_version version:"1.2.0"
One command. New version created. Metadata pushed. Release notes in 20+ languages. Done.
Step 4 — Create the deploy lane
This is the main lane. It takes your Flutter IPA, uploads it to App Store Connect, and optionally submits for review.
First, build your Flutter app:
flutter build ipa --release
This generates an IPA file in build/ios/ipa/. Now the deploy lane picks it up:
desc "Build and deploy app to App Store Connect"
lane :deploy do |options|
load_api_key
get_push_certificate
project_root = File.expand_path("../../", __dir__)
ipa_path = Dir.glob(
File.join(project_root, "build/ios/ipa/*.ipa")
).first
if ipa_path.nil?
UI.user_error!("Could not find IPA file.")
end
UI.success("Found IPA at: #{ipa_path}")
release_notes = options[:version] ? load_release_notes : nil
upload_to_app_store(
ipa: ipa_path,
skip_metadata: true,
skip_screenshots: true,
force: true,
submit_for_review: options[:submit_for_review] || false,
automatic_release: options[:automatic_release] || false,
metadata_path: "./fastlane/metadata",
release_notes: release_notes,
precheck_include_in_app_purchases: false,
submission_information: {
export_compliance_uses_encryption: false,
export_compliance_encryption_updated: false,
},
)
end
Deploy without submitting for review:
fastlane deploy
Deploy and submit for review in one shot:
fastlane deploy submit_for_review:true
The submission_information block handles Apple's export compliance questions automatically. No more clicking through those dialogs.
The full Fastfile
Here is the complete Fastfile putting it all together:
default_platform(:ios)
platform :ios do
def load_api_key
app_store_connect_api_key(
key_id: ENV["FASTLANE_APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["FASTLANE_APP_STORE_CONNECT_API_ISSUER_ID"],
key_filepath: ENV["FASTLANE_APP_STORE_CONNECT_API_KEY_PATH"],
in_house: false,
)
end
def load_release_notes(metadata_path = nil)
metadata_path ||= File.join(__dir__, "metadata")
fallback = File.read(
File.join(metadata_path, 'en-US/release_notes.txt')
)
locales = %w[ar-SA ca cs da de-DE el en-AU en-CA en-GB en-US
es-ES es-MX fi fr-CA fr-FR he hi hr hu id it ja
ko ms nl-NL no pl pt-BR pt-PT ro ru sk sv th tr
uk vi zh-Hans zh-Hant]
locales.each_with_object({}) do |locale, hash|
file = File.join(metadata_path, locale, 'release_notes.txt')
hash[locale] = File.exist?(file) ? File.read(file) : fallback
end
end
lane :download_app_metadata do
load_api_key
deliver(
download_metadata: true,
download_screenshots: false,
force: true,
)
end
lane :release_new_version do |options|
load_api_key
produce(
app_identifier: "com.yourcompany.yourapp",
app_version: options[:version],
)
release_notes = load_release_notes('./metadata')
deliver(
app_version: options[:version],
skip_binary_upload: true,
force: true,
submit_for_review: false,
automatic_release: false,
metadata_path: "./fastlane/metadata",
release_notes: release_notes,
precheck_include_in_app_purchases: false,
skip_screenshots: true,
)
end
lane :deploy do |options|
load_api_key
get_push_certificate
project_root = File.expand_path("../../", __dir__)
ipa_path = Dir.glob(
File.join(project_root, "build/ios/ipa/*.ipa")
).first
if ipa_path.nil?
UI.user_error!("Could not find IPA file.")
end
release_notes = options[:version] ? load_release_notes : nil
upload_to_app_store(
ipa: ipa_path,
skip_metadata: true,
skip_screenshots: true,
force: true,
submit_for_review: options[:submit_for_review] || false,
automatic_release: options[:automatic_release] || false,
metadata_path: "./fastlane/metadata",
release_notes: release_notes,
precheck_include_in_app_purchases: false,
submission_information: {
export_compliance_uses_encryption: false,
export_compliance_encryption_updated: false,
},
)
end
end
No reasons to not have metadata for all locales
One of the best reason to handle metadata with Fastlane is that you can version control your ASO.
(ASO = App Store Optimization. The art of writing good descriptions, release notes, and keywords to rank higher in search results. This is SEO for the App Store.)
When your metadata is in git, you can see the history of every change, revert if needed, and even have translators submit PRs with updated descriptions and release notes.
But don't translate litterally everything.
Some countries, even if they are non-English, will have better ASO with English metadata.
I'll write another article about ASO best practices.
My typical deploy workflow
Here is what a release looks like in practice:
# 1. Build the Flutter app
flutter build ipa
# 2. Update release notes in metadata/en-US/release_notes.txt
# (and other locales if needed)
# 3. Deploy to App Store Connect
cd ios
fastlane deploy version:"1.2.0" submit_for_review:true
Three commands. That is it. No Xcode, no web browser, no manual form filling.
What used to take 20-30 minutes of clicking through App Store Connect now takes under 2 minutes of actual work.
The upload itself still takes a few minutes depending on your binary size, but you are free to do something else while it runs.
Bonus I also made a script to fetch the pubspec version and run all commands in one go.
And for Android too but this article is about iOS so I won't go into that.
What about first release?
Start by making all metadata research for US market. Write your description, release notes, and keywords in English.
Then use a script to translate description, promotional text to all other languages.
For title, subtitle, and keywords I tend to keep writing them manually as they require some research and creativity for each market.
Don't forget screenshots.
I made a plugin to automate screenshot translations using figma. If that interests you, let me know and I can write an article about it.
Tips and common pitfalls
Store your API key securely
Never commit the .p8 file to git. Add it to .gitignore and use environment variables or your CI's secret store.
Use force: true carefully
The force: true flag skips Fastlane's HTML report preview. This is fine for CI, but when running locally for the first time, remove it to double-check what Fastlane will push.
Keep metadata in version control
Your metadata/ folder should be committed to git. This gives you a history of every release note change and makes it easy for translators to submit PRs.
Handle missing locales gracefully
The load_release_notes helper above falls back to English for any missing locale. This prevents submission failures when you add a new language to your app but haven't translated the release notes yet.
Export compliance
The submission_information block in the deploy lane answers Apple's export compliance questions automatically. If your app uses encryption beyond standard HTTPS, you will need to adjust these values.
Stop deploying manually
Every minute you spend clicking through Xcode and App Store Connect is a minute you are not building features or fixing bugs.
Fastlane is a one-time setup that pays for itself on every single release. If you are shipping a Flutter app to the App Store, this is not optional. It is infrastructure.
Set it up once. Deploy with one command. Move on to the work that actually matters.
If you want to skip the boilerplate entirely and start with a Flutter project that already includes deployment automation, authentication, subscriptions, and everything else you need to ship, check out ApparenceKit. It is built for developers who want to ship fast and focus on their product.
Top comments (0)