CI/CD Setup for Mobile Applications via GitLab CI
GitLab CI is optimal for teams already using GitLab repositories (self-hosted or cloud). Configuration in .gitlab-ci.yml, runners—GitLab-managed or self-hosted. For iOS builds you need a registered macOS self-hosted runner: GitLab SaaS provides only Linux/Windows runners in standard plans.
Self-Hosted macOS Runner for iOS
Runner registration:
# On Mac mini or MacBook that will be CI machine
brew install gitlab-runner
gitlab-runner register \
--url https://gitlab.com \
--registration-token $RUNNER_TOKEN \
--executor shell \
--description "macos-m2-runner"
gitlab-runner start
executor shell—runner executes commands directly in shell, without Docker container. For iOS this is the only realistic option, as Xcode doesn't work in Docker.
Important: runner must operate as LaunchDaemon, not user process, otherwise CI stops after Mac reboot. Configure via sudo gitlab-runner install --user runner.
.gitlab-ci.yml Structure for iOS + Android
stages:
- test
- build
- distribute
variables:
FASTLANE_SKIP_UPDATE_CHECK: "true"
BUNDLE_PATH: vendor/bundle
.ios_job:
tags:
- macos-m2
before_script:
- bundle install --path $BUNDLE_PATH
.android_job:
image: androidsdk/android-34
tags:
- linux-docker
ios:test:
extends: .ios_job
stage: test
script:
- bundle exec fastlane test
artifacts:
reports:
junit: fastlane/test_output/report.junit
paths:
- fastlane/test_output/
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
ios:beta:
extends: .ios_job
stage: distribute
script:
- bundle exec fastlane beta
environment:
name: beta
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [ios:test]
android:build:
extends: .android_job
stage: build
script:
- ./gradlew test assembleRelease
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- .gradle/
- vendor/bundle
artifacts:
paths:
- app/build/outputs/apk/release/
expire_in: 3 days
Code Signing via GitLab CI/CD Variables
GitLab stores secrets in Settings → CI/CD → Variables. For iOS code signing:
before_script:
- echo "$MATCH_KEYSTORE" | base64 -d > /tmp/match.keystore
- bundle exec fastlane match adhoc --readonly true
MATCH_KEYSTORE and MATCH_PASSWORD—masked variables in GitLab. Masked variables don't appear in logs even with echo.
For App Store Connect API Key—File-type variable with .p8 file:
- echo "$ASC_API_KEY" > /tmp/AuthKey.p8
- export APP_STORE_CONNECT_API_KEY_PATH=/tmp/AuthKey.p8
Caching
GitLab CI cache is key-based. For CocoaPods:
cache:
key:
files:
- Podfile.lock
paths:
- Pods/
- vendor/bundle
key.files—cache invalidates automatically on Podfile.lock change. For Gradle similarly via .gradle.
Environments and Branch-Based Deploy
ios:staging:
stage: distribute
script:
- bundle exec fastlane beta
environment:
name: staging
rules:
- if: $CI_COMMIT_BRANCH == "develop"
ios:production:
stage: distribute
script:
- bundle exec fastlane release
environment:
name: production
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual # Requires confirmation in UI
when: manual for production—click button in GitLab UI as gate before release. Useful when QA must confirm before App Store submission.
Timeline
Basic setup (macOS runner, test + beta lanes): 3–5 days. Full configuration with environments, Android pipeline, caching, review-apps: 1.5–2 weeks. Cost calculated individually.







