Back to all articles

Mobile App CI/CD & Automated Deployment: Complete Guide 2025

Manual app releases are slow, error-prone, and don't scale. Teams with CI/CD ship 10x faster with 90% fewer errors. Automated pipelines turn hours of manual work into minutes of automated reliability. This guide shows you how to build bulletproof CI/CD pipelines for iOS and Android that test, build, and deploy automatically.

Why CI/CD Matters

Benefits of Automation

  • Speed: Deploy in minutes, not hours
  • Reliability: Eliminate human error
  • Consistency: Same process every time
  • Confidence: Automated tests catch issues
  • Frequency: Ship multiple times per day
  • Documentation: Pipeline is living documentation
  • Feedback: Fast feedback on every commit

CI/CD Pipeline Overview

Typical mobile CI/CD pipeline:

1. Code Push
   ↓
2. Automated Tests
   - Unit tests
   - Integration tests
   - UI tests
   - Lint/Format checks
   ↓
3. Build App
   - Debug build (PR)
   - Release build (main)
   - Increment version
   ↓
4. Beta Distribution
   - TestFlight (iOS)
   - Internal Testing (Android)
   - Firebase App Distribution
   ↓
5. Production Release
   - App Store submission
   - Google Play release
   - Staged rollout
   ↓
6. Monitor & Rollback
   - Crash monitoring
   - Analytics
   - Rollback if issues

CI (Continuous Integration):
- Run on every commit/PR
- Test code changes
- Build app
- Fast feedback (<10 min)

CD (Continuous Deployment):
- Run on main/release branch
- Deploy to TestFlight/Beta
- Submit to stores
- Automated releases

Tool Selection

Platform Comparison

GitHub Actions:
✓ Free for public repos
✓ 2000 min/month free (private)
✓ Native GitHub integration
✓ Large marketplace
✓ Easy setup with YAML
✗ macOS minutes expensive ($0.08/min)
Best for: Most projects, GitHub users

Bitrise:
✓ Mobile-focused
✓ Pre-built workflows
✓ Great iOS/Android support
✓ Free tier available
✗ Expensive at scale
✗ Proprietary platform
Best for: Mobile-first teams

CircleCI:
✓ Fast builds
✓ macOS support
✓ Docker-based
✓ Free tier available
✗ Complex pricing
✗ Configuration complex
Best for: Large teams

GitLab CI:
✓ Self-hosted option
✓ Integrated with GitLab
✓ Unlimited minutes (self-hosted)
✗ No native macOS support
✗ Setup complexity
Best for: Self-hosted needs

Codemagic:
✓ Flutter-focused
✓ Easy setup
✓ Good free tier
✗ Expensive for high usage
Best for: Flutter apps

Fastlane:
✓ Free, open source
✓ Powerful automation
✓ Works with any CI
✗ Ruby-based (learning curve)
✗ Requires separate CI platform
Best for: Complex automation

Recommendation:
GitHub Actions + Fastlane
- Best combination
- Free for most use cases
- Flexible and powerful

Fastlane Setup

Installation

# Install Fastlane
brew install fastlane

# Or with RubyGems
sudo gem install fastlane -NV

# Initialize in your project
cd your-app-directory
fastlane init

# iOS: Choose option (1-4)
# Android: Confirm package name

This creates:
fastlane/
  Fastfile       # Main configuration
  Appfile        # App identifiers
  Pluginfile     # Plugins (if any)

iOS Fastlane Configuration

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("[email protected]")
team_id("TEAMID123")

# fastlane/Fastfile
default_platform(:ios)

platform :ios do

  # Lane for running tests
  lane :test do
    run_tests(
      scheme: "YourApp",
      devices: ["iPhone 14", "iPhone 14 Pro"],
      clean: true
    )
  end

  # Lane for beta deployment
  lane :beta do
    # Increment build number
    increment_build_number(xcodeproj: "YourApp.xcodeproj")

    # Build app
    build_app(
      scheme: "YourApp",
      export_method: "app-store",
      export_options: {
        provisioningProfiles: {
          "com.yourcompany.yourapp" => "YourApp AppStore"
        }
      }
    )

    # Upload to TestFlight
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      notify_external_testers: false
    )

    # Send Slack notification (optional)
    slack(
      message: "New beta build uploaded to TestFlight!",
      success: true
    )
  end

  # Lane for App Store release
  lane :release do
    # Capture screenshots
    capture_screenshots

    # Increment build number
    increment_build_number(xcodeproj: "YourApp.xcodeproj")

    # Build app
    build_app(
      scheme: "YourApp",
      export_method: "app-store"
    )

    # Upload to App Store
    upload_to_app_store(
      submit_for_review: false,  # Manual review submission
      automatic_release: false,
      skip_metadata: false,
      skip_screenshots: false
    )

    # Commit version bump
    commit_version_bump(
      message: "Version bump",
      xcodeproj: "YourApp.xcodeproj"
    )

    # Tag release
    add_git_tag(
      tag: "ios-#{get_version_number}-#{get_build_number}"
    )

    push_to_git_remote

    slack(
      message: "New version submitted to App Store!",
      success: true
    )
  end

  # Lane for screenshots
  lane :screenshots do
    capture_screenshots(
      scheme: "YourAppUITests",
      devices: [
        "iPhone 14 Pro Max",
        "iPhone 14 Pro",
        "iPhone 14",
        "iPhone SE (3rd generation)",
        "iPad Pro (12.9-inch) (6th generation)"
      ],
      languages: ["en-US"],
      output_directory: "./fastlane/screenshots"
    )
  end

end

Android Fastlane Configuration

# fastlane/Appfile
json_key_file("path/to/your/google-play-key.json")
package_name("com.yourcompany.yourapp")

# fastlane/Fastfile
default_platform(:android)

platform :android do

  # Lane for running tests
  lane :test do
    gradle(
      task: "test",
      build_type: "Debug"
    )
  end

  # Lane for beta deployment
  lane :beta do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle"
    )

    # Build release APK/AAB
    gradle(
      task: "bundle",  # or "assemble" for APK
      build_type: "Release"
    )

    # Upload to internal testing
    upload_to_play_store(
      track: "internal",
      release_status: "draft",
      skip_upload_apk: true,
      aab: "app/build/outputs/bundle/release/app-release.aab"
    )

    slack(
      message: "New beta uploaded to Play Console!",
      success: true
    )
  end

  # Lane for production release
  lane :release do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle"
    )

    # Build release AAB
    gradle(
      task: "bundle",
      build_type: "Release"
    )

    # Upload to production
    upload_to_play_store(
      track: "production",
      release_status: "draft",  # or "completed" for immediate release
      skip_upload_apk: true,
      aab: "app/build/outputs/bundle/release/app-release.aab",
      rollout: "0.1"  # 10% staged rollout
    )

    # Commit version bump
    git_commit(
      path: ["./app/build.gradle"],
      message: "Version bump"
    )

    # Tag release
    add_git_tag(
      tag: "android-#{get_version_name}-#{get_version_code}"
    )

    push_to_git_remote

    slack(
      message: "New version released to Play Store!",
      success: true
    )
  end

  # Lane for screenshots
  lane :screenshots do
    gradle(
      task: "assemble",
      build_type: "Debug"
    )

    capture_android_screenshots(
      locales: ["en-US"],
      clear_previous_screenshots: true
    )
  end

end

GitHub Actions CI/CD

iOS Pipeline

# .github/workflows/ios.yml

name: iOS CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

env:
  DEVELOPER_DIR: /Applications/Xcode_15.1.app/Contents/Developer

jobs:
  test:
    name: Test
    runs-on: macos-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.2'
        bundler-cache: true

    - name: Install dependencies
      run: |
        bundle install
        pod install

    - name: Run tests
      run: bundle exec fastlane test

    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: fastlane/test_output/

  beta:
    name: Deploy to TestFlight
    runs-on: macos-latest
    needs: test
    if: github.ref == 'refs/heads/main'

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0  # Full history for version bumps

    - name: Setup Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.2'
        bundler-cache: true

    - name: Install dependencies
      run: |
        bundle install
        pod install

    - name: Setup App Store Connect API Key
      run: |
        mkdir -p ~/private_keys
        echo "${{ secrets.APP_STORE_CONNECT_API_KEY }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8

    - name: Import certificates
      env:
        CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }}
        CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
        KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
      run: |
        # Create keychain
        security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
        security default-keychain -s build.keychain
        security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
        security set-keychain-settings -t 3600 -u build.keychain

        # Import certificate
        echo "$CERTIFICATE_P12" | base64 --decode > certificate.p12
        security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
        security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain

        rm certificate.p12

    - name: Download provisioning profiles
      env:
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
        MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
      run: bundle exec fastlane match appstore --readonly

    - name: Build and deploy to TestFlight
      env:
        FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
        APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
        APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
        APP_STORE_CONNECT_API_KEY_PATH: ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8
      run: bundle exec fastlane beta

    - name: Upload build artifacts
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: ios-build
        path: |
          *.ipa
          fastlane/builds/

# Setup secrets in GitHub repo settings:
# APP_STORE_CONNECT_API_KEY - API key content
# APP_STORE_CONNECT_KEY_ID - Key ID
# APP_STORE_CONNECT_ISSUER_ID - Issuer ID
# CERTIFICATE_P12 - Base64 encoded .p12 certificate
# CERTIFICATE_PASSWORD - Certificate password
# KEYCHAIN_PASSWORD - Temporary keychain password
# MATCH_PASSWORD - Fastlane match password
# MATCH_GIT_BASIC_AUTHORIZATION - Git credentials for match repo
# FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD - App-specific password

Android Pipeline

# .github/workflows/android.yml

name: Android CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        distribution: 'temurin'
        java-version: '17'
        cache: 'gradle'

    - name: Setup Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.2'
        bundler-cache: true

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Run unit tests
      run: ./gradlew test --stacktrace

    - name: Run instrumented tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 30
        target: google_apis
        arch: x86_64
        script: ./gradlew connectedCheck

    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: |
          app/build/reports/
          app/build/test-results/

  beta:
    name: Deploy to Play Store Beta
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        distribution: 'temurin'
        java-version: '17'
        cache: 'gradle'

    - name: Setup Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.2'
        bundler-cache: true

    - name: Decode keystore
      env:
        KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
      run: |
        echo "$KEYSTORE_BASE64" | base64 --decode > app/release.keystore

    - name: Decode Google Play API key
      env:
        PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
      run: |
        echo "$PLAY_STORE_JSON_KEY" > play-store-key.json

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Build and deploy to Play Store
      env:
        KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
        KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
        KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
      run: |
        bundle install
        bundle exec fastlane beta

    - name: Upload build artifacts
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: android-build
        path: |
          app/build/outputs/bundle/release/
          app/build/outputs/apk/release/

# Setup secrets in GitHub repo settings:
# KEYSTORE_BASE64 - Base64 encoded release keystore
# KEYSTORE_PASSWORD - Keystore password
# KEY_ALIAS - Key alias
# KEY_PASSWORD - Key password
# PLAY_STORE_JSON_KEY - Google Play service account JSON

Best Practices

Security

Never commit:
✗ API keys
✗ Signing certificates
✗ Keystore files
✗ Provisioning profiles
✗ Service account JSONs
✗ Passwords

Use secrets:
✓ GitHub Secrets (encrypted)
✓ Environment variables
✓ Fastlane match (encrypted git)
✓ Encrypted artifacts
✓ Vault/secret managers

Rotate credentials:
- Every 90 days minimum
- After team member leaves
- If potentially exposed
- Store securely (1Password, etc.)

Performance

Speed up CI/CD:

Caching:
✓ Dependencies (gems, pods, gradle)
✓ Build artifacts
✓ Docker layers

Parallelization:
✓ Run tests in parallel
✓ Split iOS/Android jobs
✓ Matrix builds

Optimization:
✓ Only run relevant tests
✓ Incremental builds
✓ Skip unnecessary steps
✓ Use faster machines

Target times:
- PR checks: < 10 minutes
- Beta deployment: < 20 minutes
- Full release: < 30 minutes

Conclusion

CI/CD transforms mobile development from manual, error-prone releases to automated, reliable deployments. With GitHub Actions and Fastlane, you can build a professional pipeline that tests, builds, and ships your app automatically. The initial setup investment pays massive dividends in speed, reliability, and developer happiness. Ship faster, ship safer, ship more often.

Automated deployments ensure consistent quality, but users still need great support. Our support URL generator creates the professional support pages required for App Store and Google Play submissions, complementing your automated release pipeline with automated support infrastructure.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.