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.
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[da de-DE en-CA en-US es-ES fr-FR he it ja ko nl-NL
no pl pt-BR pt-PT sv tr zh-Hans zh-Hant ar-SA hu]
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.
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[da de-DE en-CA en-US es-ES fr-FR he it ja ko
nl-NL no pl pt-BR pt-PT sv tr zh-Hans zh-Hant
ar-SA hu]
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
My typical deploy workflow
Here is what a release looks like in practice:
# 1. Build the Flutter app
flutter build ipa --release
# 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.
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.