After three years on FlutterFlow's managed build service, we relaunched 928uk as a hand-rewritten Flutter app this month, published to both the App Store and Google Play via their respective review processes. The work spanned several weeks and the end result is: a clean codebase, a self-hosted CI/CD pipeline, and learning a stack of small App Store-review compliance items that you only learn about by tripping over them. Here is the shape of the journey.
TL:DR – Two weeks to refactor a and publsh a mobile app in Flutter with repeatable local control of automated builds.
Contents
Why move off FlutterFlow
FlutterFlow earned its place at the start. The original 928uk app went from nothing to something the community actually used in weeks rather than months. But as it matured we wanted control over architecture, dependencies, signing and deployment. We wanted to be able to add a new dependency without waiting for the platform to support it, to read every line of generated code in our own repository, and to ship a fix the same hour we wrote it. The decision was not about FlutterFlow being bad at all, it was about a project that we had the expertise to build locally without monthly subcription model.
Setting up the build pipeline
The first deep investment was the CI/CD pipeline, which we documented separately in an earlier post. Briefly: Gitea and a macOS runner on a self-hosted Mac Studio, Fastlane Tools installed via Homebrew alongside Flutter, CocoaPods and Ruby. Tags drive everything! Push v2.0.0-beta73 and the runner archives, signs and uploads to TestFlight and Google Play's internal track in a single workflow. The combination of self-hosted hardware and tag-based triggers gave us an iteration cycle measured in minutes rather than the half-hour minimum that comes with cloud runners and queue times.
Apple code signing is the real time sink
The bulk of the early CI work went into Apple code signing. iOS provisioning profiles, certificates, entitlements and the App ID configuration are layered in a way that only reveals itself when something fails. We needed Push Notifications, Sign in with Apple and Apple Pay capabilities all enabled on the same App ID, and each addition triggered a profile regeneration whose new timestamp had to be synchronised across the Xcode project file and the Fastfile. The build machine itself runs Fastlane Match's certificates from the System keychain rather than the login keychain, because act_runner as a brew service has no proper macOS security session and silently corrupts the keychain search list if you let it. Once we accepted that constraint and put the certificate where the runner could see it, signing became boring, which is exactly what you want from signing.
Cloud Functions migration
Firebase Cloud Functions needed their own clean slate. The FlutterFlow project shipped a 14-dependency package.json with Stripe, Mux, OneSignal, LangChain and a handful of other services that we never wired into our app, plus push-notification triggers tied to a ff_push_notifications collection that the new app does not write to. We trimmed to two dependencies (firebase-admin and firebase-functions), kept only addFcmToken and onUserDeleted, and rewrote the deletion trigger so it actually removes the user's car data, FCM tokens and Storage uploads in addition to the user document. Apple and Google both require apps that allow account deletion to actually delete the data, not just the auth record.
App Review preparation
A pre-submission audit caught a handful of compliance issues that were easy to fix but easy to miss. The router required authentication for every screen, which Apple flags because public content should be browseable without an account. We inverted the redirect so only personal screens (My Car, Profile, account deletion) force a sign-in. Sign in with Apple was wired in to the code but never appeared on the auth screen because the entitlements file did not exist; without it, Apple rejects any app that offers a competing third-party social login. The encryption compliance question was answered automatically by adding ITSAppUsesNonExemptEncryption = false to Info.plist, since HTTPS is exempt under Category 5 Part 2. None of these are hard. All of them quietly cost a review cycle if you miss them.
Push notifications and a remote kill switch
FCM token registration was already present from the FlutterFlow build, but iOS was silently dropping every notification because no aps-environment entitlement was set and there were no foreground / tap handlers. We wired onMessage, onMessageOpenedApp and getInitialMessage to a broadcast stream of route names and let the app's GoRouter handle navigation, so a notification with data: { route: '/articles/123' } opens the article whether the app was foregrounded, backgrounded, or cold-started. Alongside push we built a kill screen: a Firestore document at config/app with killed: true or a min_supported_build_number, watched live by the app, that swaps the entire router for an "Update required" screen with store buttons. Probably only useful less than once a year, invaluable when you need it.
Integrations and polish
Around the structural work sat the smaller pieces: AdMob banners at the bottom of detail pages and as in-feed slots in long lists, Firebase Analytics screen views via a GoRouter NavigatorObserver, theme-aware logo treatment so dark mode does not eat the brand, and a homepage alert system that publishes Bootstrap-style typed banners (success, info, warning, danger, light) from a Firestore collection. The Add Car form was rebuilt with structured year/model/variant dropdowns sourced from a Firestore variations collection, gearbox radio, sticker badges, location field, and a three-state Private / For Sale / Sold segmented control. None of this is groundbreaking; the point is the codebase is now small enough to add things to it in an afternoon, where the FlutterFlow build had grown to the point where simple changes were less easy and meant rebuilding generated code.
Submission and publication
App icons were regenerated via flutter_launcher_icons from a single source PNG. Screenshots were captured manually from current-generation simulators (iPhone 17 Pro Max for App Store, Pixel 7 for Play) following a documented six-screen recipe. The app was submitted to both stores. App Store approval came back inside the standard window with no requested changes; Play's internal track and production rollout went through without intervention. Total elapsed from "we should probably leave FlutterFlow" to "live on both stores" was two weeks, with the heaviest part being the automation of builds through our continuous integration (CI) system based on Mac computers along with Apple signing work and the careful App Review compliance pass.
Lessons
Don't fight macOS (DFMOS). macOS isn't Linux. Homebrew, the missing macOS package manager is the perfect solution for repeatable, updateable tooling required to make a Mac into a CI system but it comes at a cost which is that it best runs in a user context. Don't fight this. Take the time to set up your tooling in and it will survive macOS updates and Homebrew tool updates. Ignore this and you will make it brittle or difficult.
Self-hosted CI repays itself fast. The Mac Studio paid for itself the first weekend we hit the signing rabbit hole, because the iteration loop is bounded by build time and not by queue time. A five-minute build is genuinely five minutes, every time.
Trim aggressively when you migrate. Much of the FlutterFlow plumbing was unused. Carrying it forward "just in case" would have meant re-auditing all of it for the privacy manifest and dependency review. Easier to delete and bring back the pieces we needed.
Account deletion is non-negotiable. Both stores require it, and they require it to actually delete the data, not just the auth record. A Cloud Function trigger that cascades the deletion is twenty lines of code and saves a review cycle.
Hot reload in Flutter is still really cool. Making tweaks to padding, colours, test clipping or sizes is far easier with hot reload.
Test sign-in flows on a real device early. Simulator behaviour is different enough that several silent failures (missing URL scheme, missing client-ID Info.plist key, SHA fingerprints not registered with Firebase) only revealed themselves on physical hardware. The earlier in the cycle you have a real-device build, the less time is lost to this class of issue.
Document as you go. The repository's documentaion grew alongside the work and is now the single source of truth for new build machines, profile renewal, secrets, deployment, and even how to post a homepage alert. Future-us will read it long before having to read the code.
End result
Developer builds now run locally instead of relying on a cloud service provider meaning I can use local emulators test devices and all the Flutter developer tools including Hot reload are available. Tagged code commits and pushes to my local Gitea hosted repository which launches a macOS runner to create automatic iOS and Android builds all the way through to TestFlight and Google Plays Test tracks. Zero intervention. 100% Repeatable. All running locally.