DEV Community

Mykhailo Dorokhin
Mykhailo Dorokhin

Posted on

How I Moved a KMP Project from Scratch to Production on 5 Platforms (and What I Learned)

I'm moving a small Kotlin Multiplatform project to production across 5 platforms. This is my experience so far.

Overall? Surprisingly positive. Web, Android, and iOS are live. Desktop and CLI are working locally, distribution coming soon. I recorded all my coding sessions and they are on YouTube.

The project is ongoing. Here's what actually broke along the way.

What's Working

Platform Status Link
Web Live justusefuckingkotlin.com
Android Beta Play Store
iOS TestFlight Join Beta
Desktop Local Coming soon
CLI Local Coming soon

Same Kotlin code compiles to WASM, JVM, and Native.

Stack

  • Kotlin 2.3.0 + Compose Multiplatform 1.9.3
  • GitHub Actions for CI/CD
  • Cloudflare Pages (web)
  • fastlane (iOS + Android)
  • ktlint + detekt

Links


9 Problems Nobody Warned Me About

1. macOS App Store rejects KMP apps

Apple requires every binary signed: your app, JVM runtime, Kotlin native libs, every .dylib. Each needs provisioning profiles. One unsigned file = instant rejection.

Our workaround: skip Mac App Store. Distribute via DMG (signed + notarized, simpler) in Github Releases.

2. WASM vs JS: Material Icons Extended breaks Webpack

Tried JS target for compatibility. Webpack couldn't optimize Material Icons Extended. Switched back to WASM.

If you need extended icons on web, stick with wasmJs.

3. detekt silently skips KMP code

./gradlew detekt passes but checks nothing. You need source-set-specific tasks:

./gradlew \
  detektMetadataCommonMain \
  detektAndroidMain \
  detektIosArm64Main \
  detektDesktopMain \
  detektWasmJsMain
Enter fullscreen mode Exit fullscreen mode

detektMetadataCommonMain is your shared code. Without it, CI is green while checking zero lines.

4. iOS fastlane version injection fails silently with GENERATE_INFOPLIST_FILE = true

When GENERATE_INFOPLIST_FILE is set to true in your KMP project, fastlane actions like increment_version_number have no effect because Xcode generates Info.plist dynamically.

Fix: Either set GENERATE_INFOPLIST_FILE = false in your Xcode project settings, or inject version properties directly via xcargs in your Fastfile:

build_app(
  xcargs: [
    "MARKETING_VERSION=#{sanitized_version}",
    "CURRENT_PROJECT_VERSION=#{build_number}"
  ].join(" ")
)
Enter fullscreen mode Exit fullscreen mode

5. Cloudflare Pages needs branch: main for tag deployments

When deploying from a git tag, Cloudflare doesn't know your branch. Without this, deployments go to preview URLs instead of production. This snippet was adapted from the official Cloudflare Workers CI/CD documentation for GitHub Actions: https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/

- name: Deploy to Cloudflare
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: pages deploy ./composeApp/build/dist/wasmJs/productionExecutable --project-name=${{ inputs.cloudflare_project_name }} --branch main # Required for tags/production deployments
Enter fullscreen mode Exit fullscreen mode

6. Desktop packaging fails with version 0.x.y

DMG, MSI, PKG all require major version ≥ 1. And packageVersion must be set BEFORE targetFormats() — that call triggers validation immediately.

nativeDistributions {
    val pkgVersion = if (version.startsWith("0.")) "1.0.0" else version
    packageVersion = pkgVersion  // Set first
    targetFormats(Dmg, Msi, Deb)  // Validates here
}
Enter fullscreen mode Exit fullscreen mode

7. Windows build validates ALL desktop formats

createDistributable on Windows still validates DMG/PKG and fails. Use the platform-specific task:

# Wrong
run: ./gradlew createDistributable

# Right
run: ./gradlew packageMsi
Enter fullscreen mode Exit fullscreen mode

8. Unicode arrows don't render on Compose Web

and show as boxes. Canvas rendering doesn't handle Unicode fonts well.

// Broken
Text("→")

// Works
Icon(Icons.AutoMirrored.Filled.ArrowForward, null)
Enter fullscreen mode Exit fullscreen mode

Requires compose.materialIconsExtended dependency.

9. A surprising 3x CI speed boost from setup-gradle

I knew that caching dependencies with gradle/actions/setup-gradle would make CI faster. What I didn't expect was how much faster. For this KMP project, adding this single line cut the build time by nearly 3x, from over 6 minutes to just 2.

- uses: gradle/actions/setup-gradle@v4
Enter fullscreen mode Exit fullscreen mode

It caches dependencies and build outputs. Without it, every CI run downloads everything fresh, which is especially painful with multi-platform artifacts.


KMP tooling is ready. These are edge cases, not blockers. If you've been waiting to try it — now's the time.

Top comments (0)