Flutter + Android SDK Docker images
Docker images with Flutter and the Android SDK baked in, for both linux/amd64
and linux/arm64. They follow the latest stable Flutter and live on GHCR.
They're OCI-native images (OCI media types, standard labels and annotations), so they run anywhere: any CI, any container runtime.
- Images
- Quick start
- What's in the image
- Preparing an Android build
- Architecture support
- Staying current
| Image | Tags |
|---|---|
ghcr.io/lahaluhem/flutter |
stable, plus the exact version (3.44.4, ...) |
ghcr.io/lahaluhem/android-sdk |
latest |
Every tag is a multi-arch manifest, so docker pull grabs the variant matching
your machine on its own.
Run your tests against the current directory:
docker run --rm -it -v ${PWD}:/build -w /build ghcr.io/lahaluhem/flutter:stable flutter testWant a specific Flutter version instead of stable? Swap the tag:
docker run --rm -it -v ${PWD}:/build -w /build ghcr.io/lahaluhem/flutter:3.44.4 flutter testPin the platform when you need to (otherwise it follows your host):
docker run --rm -it --platform linux/arm64 ghcr.io/lahaluhem/flutter:stable flutter doctorHere's each layer, and why it's there:
| Layer | Why it's there | What's in it |
|---|---|---|
| Base | the OS | ubuntu |
| Build toolchain | build an Android app with Flutter | the JDK, the Android SDK (cmdline-tools, platform-tools, build-tools, a platform), git, zip/unzip, curl/wget, build-essential, libstdc++6, locales |
| Convenience (DX) | handy, but not needed to build | lcov (for flutter test --coverage reports), jq and yq (JSON and YAML), plus the Dart CLIs cider and dependency_validator (see below) |
| Flutter | run flutter/dart, build apps |
Flutter cloned at the pinned version plus its bundled Dart SDK. This is the flutter image, built FROM android-sdk. |
By architecture:
flutter,dart, and the JVM tooling run natively on bothamd64andarm64. The Android SDK build tools (aapt2,cmake, the NDK) are x86-64-only, so building APKs onarm64runs them under emulation. See Architecture support.
Quiet by default: the
flutterimage ships with analytics off (Flutter and Dart) and Flutter's version-update check skipped, so CI runs stay quiet and skip the needless network call. Override with-e BOT=false.
Flutter DX tools
Two Dart CLIs are shipped as native binaries on PATH (compiled with dart compile exe).
Handy for project chores, not needed to build:
cider: version bumps andCHANGELOG.mdmanagement.dependency_validator: flags missing, unused, or mis-promoted dependencies.
They run like any other command in the image, against your mounted project:
docker run --rm -v ${PWD}:/build -w /build ghcr.io/lahaluhem/flutter:stable dependency_validator
docker run --rm -v ${PWD}:/build -w /build ghcr.io/lahaluhem/flutter:stable cider bump patchVersions track pub.dev through Renovate, like everything else here.
A flutter build apk / appbundle usually needs a few secret files staged first: a
--dart-define-from-file file, android/app/google-services.json, and a signing keystore with
android/key.properties. Rather than hand-writing that in every CI job, the image ships
ch-build-setup-android, an opt-in helper that materialises them from CH_BUILD_*
environment variables. Call it once before your build; a job that never invokes it pays nothing.
# in your build job, before `flutter build`:
ch-build-setup-android
flutter build apk --release --dart-define-from-file="$CH_BUILD_DART_DEFINE_FILE"ch-build-setup-android --help lists every variable; --dry-run shows what it would write
without writing anything.
The helper runs three independent lanes; use any combination. Two are opt-in (they do nothing unless you set their variables); signing is always-on (a get-going convenience):
| Lane | Runs when | Produces |
|---|---|---|
| dart-defines | you set any CH_BUILD_DEFINE_* (0 or more) |
the --dart-define-from-file file |
| google-services | you set all three Firebase vars (none = skip, partial = error), unless google-services.json exists |
android/app/google-services.json |
| signing | always, unless android/key.properties already exists |
a dev keystore + android/key.properties |
Opt out of signing by committing your own android/key.properties; the helper never overwrites it.
| Variable | What it does |
|---|---|
CH_BUILD_DEFINE_<KEY> |
one per dart-define, written verbatim as <KEY>=<value>; set as many as you need |
CH_BUILD_ANDROID_FIREBASE_APP_ID, CH_BUILD_FIREBASE_CLIENT_EMAIL, CH_BUILD_FIREBASE_PRIVATE_KEY |
fetch google-services.json from Firebase; see Google Services |
CH_BUILD_ANDROID_KEYSTORE_PASSWORD |
PKCS12 keystore password (default storepassword) |
CH_BUILD_ANDROID_KEY_ALIAS |
signing key alias (default development) |
CH_BUILD_ANDROID_DNAME, _KEY_VALIDITY, _KEY_SIZE, _KEY_ALG |
keytool knobs, all sensibly defaulted |
Two paths are baked into the image as environment variables, so you reference them instead of hardcoding a location:
| Variable | Default | Use it for |
|---|---|---|
CH_BUILD_DART_DEFINE_FILE |
lib/env/dart_defines.env |
pass to --dart-define-from-file |
CH_BUILD_CACHE_KEYSTORE |
android/app/ch-signing.p12 |
add to your build job's cache to keep one signing key across runs |
You can point CH_BUILD_DART_DEFINE_FILE at any path or name (e.g. lib/env/env.dev.json) and the
helper writes there. The contents are always the .env KEY=value format, not JSON, but that's
fine whatever you name it: flutter build --dart-define-from-file chooses JSON vs .env by the
file's content (a leading {), not the extension, so a .env-format file called .json is
still read correctly (flutter_tools, the startsWith('{') check).
The dart-define lane is also a standalone helper, ch-write-dart-defines (bash only, nothing else),
that ch-build-setup-android just calls. Run it on its own when you want the defines without the
Android setup, for example on a macOS iOS-build runner: ch-write-dart-defines writes the env file
and ch-fetch-firebase-config --ios writes the plist, with no keystore or signing involved.
Rather than pasting the whole google-services.json into a CI secret, the helper fetches it from
Firebase at build time (one authenticated call to the Firebase Management API) and writes
android/app/google-services.json. You give it the app id and a service-account credential:
| Variable | What it is |
|---|---|
CH_BUILD_FIREBASE_CLIENT_EMAIL |
the service account's client_email (shared across platforms) |
CH_BUILD_FIREBASE_PRIVATE_KEY |
its private_key: a PEM, base64 of a PEM, or a header-less base64 body (see below) |
CH_BUILD_ANDROID_FIREBASE_APP_ID |
the Android app id, for --android (1:<num>:android:<hash>) |
CH_BUILD_IOS_FIREBASE_APP_ID |
the iOS app id, for --ios (1:<num>:ios:<hash>) |
The credential is project-scoped, so CH_BUILD_FIREBASE_* is shared across platforms; only the app
id is per-platform. Use the plain App ID from the Firebase console (1:<num>:<platform>:<hash>),
not the Encoded app ID (app-1-...): the helper looks for the :android: / :ios: marker, so it
rejects the encoded form. The credential plus the app id for the platform you fetch go together: set
none and that step is skipped; set some but not all and it fails fast. If the config file already
exists it's left untouched (so a committed one is kept and re-runs skip the fetch); pass --force
to refresh it.
The helper accepts the private key in several shapes, so you can use whichever your CI variable UI
will store. Masked / whitespace-checked variables (GitLab) want a single line with no spaces, which
the raw PEM fails because its -----BEGIN PRIVATE KEY----- header contains spaces:
Value of CH_BUILD_FIREBASE_PRIVATE_KEY |
Single line, no spaces | Helper accepts |
|---|---|---|
multi-line PEM (-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----) |
✗ | ✓ |
PEM on one line, \n-escaped (-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----) |
✗ (header has spaces) | ✓ |
| body only, multi-line (base64 lines, no header/footer) | ✗ | ✓ |
body only on one line, \n-escaped (MIIEv...\n...IDAQAB) |
✓ | ✓ |
base64 of the whole PEM, one line (LS0tLS1CRUdJ...) |
✓ | ✓ |
| empty, or not a private key | ✗ (errors at signing) |
So for GitLab store either your \n-escaped body or base64 of the key; both are single-line and
space-free, and base64 is the tidiest (and maskable):
jq -r .private_key key.json | openssl base64 -AThe service account needs a single permission, firebase.clients.get (the predefined Firebase
Viewer role includes it, and nothing more is required). There's no Firebase CLI in the image; the
fetch is done by a small standalone helper, ch-fetch-firebase-config, that needs only
curl/jq/openssl. ch-build-setup-android calls it with --android, but it runs on its own
too (handy on a non-chrysalis runner, where you can fetch it straight from the repo):
ch-fetch-firebase-config --android # -> android/app/google-services.json (skips if present)
ch-fetch-firebase-config --android --force # re-fetch even if the file already exists
ch-fetch-firebase-config --android --dry-run # show what it would do, fetch nothingThe same fetcher handles iOS: ch-fetch-firebase-config --ios writes
ios/Runner/GoogleService-Info.plist from CH_BUILD_IOS_FIREBASE_APP_ID and the same shared
credential. iOS builds need macOS, so that path is for a macOS runner; chrysalis's own images build
Android on Linux.
Creating the service account
In the Google Cloud console for your Firebase project:
- IAM & Admin → Service Accounts → Create service account.
- Grant it a role that includes
firebase.clients.get: the predefined Firebase Viewer works, or a custom role with just that one permission. - Keys → Add key → Create new key → JSON, and download it.
- Copy
client_emailandprivate_keyfrom that JSON intoCH_BUILD_FIREBASE_CLIENT_EMAILandCH_BUILD_FIREBASE_PRIVATE_KEY. The app id is in the Firebase console under Project settings.
The helper generates a PKCS12 dev keystore and writes the key.properties that your
android/app/build.gradle reads (you still need the standard signing
config wired into your project). It
only generates when the keystore is missing, so caching CH_BUILD_CACHE_KEYSTORE keeps the key
stable; otherwise each run mints a fresh one and installed builds can't update in place.
Things to watch out for
- Run it from your project root (it looks for
pubspec.yaml). - dart-define values are written verbatim. Quote a value that contains a
#or has leading or trailing spaces; multi-line values aren't supported by--dart-define-from-file. - An existing
android/key.propertiesis left untouched, so your own signing setup wins. - Changing the password invalidates a cached keystore. Clear the cache when you rotate it; the old keystore won't open with the new password.
- The files it writes contain secrets (mode
0600). On a reused/persistent runner, clean them up after the build. - Release builds on arm64 need x86 emulation, like any Android build here (see Architecture support).
Gradle downloads its dependencies and its own distribution on the first build. Two paths are baked
in so a build job can cache them across runs (GRADLE_USER_HOME is pinned to /root/.gradle):
| Variable | Points to |
|---|---|
CH_BUILD_CACHE_GRADLE_MODULES |
$GRADLE_USER_HOME/caches/modules-2, the downloaded dependencies |
CH_BUILD_CACHE_GRADLE_DISTS |
$GRADLE_USER_HOME/wrapper/dists, the Gradle distribution the wrapper fetches |
These are deliberately narrow: caching all of ~/.gradle would also drag in daemon logs, lock
files, and execution history you don't want.
Mind the cache size. The dependency cache (
modules-2) can run to several GB. On a single-project ephemeral runner that's usually fine, but check that caching it (uploaded and downloaded every run) actually beats re-fetching, otherwise the cache itself becomes the bottleneck.
Almost everything runs natively on both arches. The one catch is building Android apps on arm64.
| Workload | amd64 | arm64 |
|---|---|---|
flutter / dart, flutter test, flutter analyze, pub, web builds |
native | native |
Android builds (flutter build apk / appbundle) |
native | needs x86 emulation |
Why arm64 Android builds need emulation
Google only publishes the Linux Android SDK build tools (aapt2, cmake,
ninja, the NDK, adb) as x86-64 binaries. There's no arm64 Linux build, so on
a native arm64 host an APK/AAB build eventually reaches for a tool it can't run,
and fails.
The fix is to register x86 emulation on the host once (Docker Desktop comes with it already):
docker run --privileged --rm tonistiigi/binfmt --install amd64After that flutter build apk works on arm64 too. It's just slower, since those
few tools run emulated while everything else stays native. The full story is in
APPENDIX.md.
Note: the Android emulator isn't in the arm64 image either. Google doesn't ship it for
linux/arm64.
Renovate checks weekly for new stable Flutter releases
and opens a PR bumping versions.env. Merge it, and the images rebuild on
the new version. The same config keeps the GitHub Actions pins and the ubuntu base image
current.