Produce a signed Android app (.aab/.apk) and a ready-to-finish iOS Xcode project from an Ionic app β using nothing but Docker.
No local Android SDK, no Xcode-on-Linux gymnastics, no "works on my machine". Drop these files into your Ionic project and build.
Note
Prefer Capacitor? See the sibling project ionic-capacitor-docker β the same Dockerized pipeline built around Capacitor (and it defaults to pnpm instead of npm). Same idea, a different "way".
- Highlights
- How it works
- Quick start
- Repository layout
- The demo app (
example-app) - Usage
- Verifying and using the Android build
- Continuous integration
- π€ Reproducible builds in Docker β a heavy, cacheable toolchain image (Ubuntu + JDK + Android SDK + Node + Cordova/Ionic CLIs) and a light per-app build on top of it.
- π Signed Android releases out of the box β Cordova reads the keystore from
build.json, so a cleancordova buildproduces an upload-ready.aab/.apk. - π iOS prepared on Linux β Cordova generates the Xcode project (and Pods, when a plugin needs them); you just finish the build on macOS.
π °οΈ Modern, zoneless stack β Angular 22 + Ionic v9 (nozone.js), esbuild builder, TypeScript 6, Vitest.- π¦ Bring your own package manager β
npm,yarn, orpnpmvia one build-arg; only the selected one is installed (no Corepack, future-proof for Node 25+). - π± Fresh platforms every build β
cordova platform addregeneratesplatforms/at build time (not committed), so the build is stateless and the bundle id is injected per build. - π― Dev/prod switching β environment files and Firebase config are swapped at build time so one optimized build can target either backend.
- β CI included β drift-guard, lint/build/test, and an end-to-end Android build; Dependabot keeps everything current.
The native platforms/ folder is generated fresh inside Docker on every build (it is not committed), so the Dockerfile injects your PACKAGE_ID into config.xml before cordova platform add runs. The Android release is signed from build.json, so the build needs no hand-edits to the generated Gradle project.
flowchart LR
A["Your Ionic app<br/>(Angular 22 + Ionic v9)"]:::app
subgraph IMG["app-builder.Dockerfile Β· build once, reuse"]
B["Toolchain image<br/>Ubuntu + JDK + Android SDK + Node + Cordova CLI"]:::img
end
subgraph APP["Dockerfile Β· per-app build"]
C["ng build β www/"]:::step --> D["cordova platform add"]:::step --> E["cordova build (release)"]:::step
end
F["Signed .aab / .apk"]:::out
G["iOS Xcode project<br/>(finish on macOS)"]:::out
A --> B --> C
E --> F
E --> G
classDef app fill:#3880FF,stroke:#285fbf,color:#fff
classDef img fill:#2496ED,stroke:#1a6cb0,color:#fff
classDef step fill:#f6f8fa,stroke:#999,color:#111
classDef out fill:#3fb950,stroke:#2b7a37,color:#fff
The bundled example-app is self-contained β clone and build it to exercise the whole pipeline end-to-end:
cd example-app
./build-mobile.shThat builds the toolchain image, builds the demo, and copies the artifact into build-output/. Pick a platform directly with ./build-mobile.sh android (or ios / all).
| Path | What it is |
|---|---|
app-builder.Dockerfile |
Builds the heavy toolchain base image (Ubuntu + JDK + Android SDK + Node + Cordova/Ionic CLIs). Build it once and reuse it. |
Dockerfile |
FROM app-builder β copies your app in, builds the Angular web app, then runs cordova build. The per-app build. |
build-mobile.sh |
Convenience wrapper that builds both images and copies the artifact out. |
example-app/ |
A small Angular 22 + Ionic v9 (zoneless) + Cordova demo you can clone and build immediately. |
Important
example-app/ carries its own copies of the template files above, because Docker can't reach files outside its build context β the copies are required, not accidental. The root files are the source of truth; the example-app copies of Dockerfile and app-builder.Dockerfile are kept byte-for-byte identical (CI enforces this). example-app/build-mobile.sh is intentionally slightly different (it uses version=0.0.0 and a self-contained header comment).
The bundled demo is a small Angular 22 + Ionic v9 + Cordova app that runs fully zoneless (no zone.js). It exercises the Docker pipeline end-to-end and doubles as an up-to-date reference for the modern toolchain:
- Angular 22 with the esbuild
@angular/build:applicationbuilder, standalone components and signals β zoneless by default (provideZonelessChangeDetection()), nozone.jsin the bundle. - Ionic pinned to a v9 pre-release dev build of
@ionic/angular(8.8.12-devβ¦). v9 adds Angular 21/22 support and zoneless-by-default. Until it ships as stable (~Q3 2026) the pin is exact. When@ionic/angular@9is released, bump the pin to^9and delete this note. - Cordova with cordova-android 15 (SDK Platform 36, Build Tools 36.0.0). The Cordova CLI is installed globally in the toolchain image.
- npm is the default package manager (
yarn/pnpmare supported too β seePACKAGE_MANAGER). The Capacitor sibling defaults to pnpm instead. - TypeScript 6, Vitest (jsdom) for unit tests, angular-eslint for linting.
- The toolchain base image builds on Ubuntu 26.04.
npm run build emits a flat www/ (so Cordova finds www/index.html), and the production configuration swaps src/environments/environment.ts for environment.prod.ts β the Dockerfile first copies environment.<ENV_NAME>.ts over environment.prod.ts so one production build can target dev or prod.
It is separated so you don't waste time rebuilding it every time you build a new app.
docker build . -f ./app-builder.Dockerfile -t app-builder
docker push app-builder # only if you distribute it to a registryOptionally pass --build-args:
docker build . -f ./app-builder.Dockerfile \
--build-arg PACKAGE_MANAGER=yarn \
--build-arg ANDROID_PLATFORMS_VERSION=36 \
-t app-builderYou can rename app-builder to whatever you like, but then change it inside Dockerfile too (the FROM app-builder line).
Docker builder arguments (defaults shown)
| Argument | Default | Notes |
|---|---|---|
GRADLE_VERSION |
8.14.5 |
Gradle installed in the image and used for the Cordova wrapper. cordova-android 15 uses an AGP 8.x plugin, so stay on Gradle 8.x. |
JAVA_VERSION |
21 (LTS) |
cordova-android 13β15 officially document JDK 17, but AGP 8.x / Gradle 8.5+ also run on JDK 21. JDK 25 would need AGP 9 / Gradle 9.1+. |
ANDROID_PLATFORMS_VERSION |
36 |
Android platform (compile/target SDK) to install. |
ANDROID_BUILD_TOOLS_VERSION |
36.0.0 |
Android build-tools version (cordova-android 15 requires Build Tools 36.0.0). |
ANDROID_SDK_TOOLS_VERSION |
14742923 |
Android command-line tools build number. |
PACKAGE_MANAGER |
npm |
npm, yarn, or pnpm. Only the selected manager is installed (npm ships with Node; yarn/pnpm are added on demand with npm install -g). This avoids Corepack, which is being unbundled from Node 25+. Also selects how Dockerfile installs your app's dependencies (npm ci / yarn install --frozen-lockfile / pnpm install --frozen-lockfile) β commit the matching lockfile. |
NODE_VERSION |
24 (LTS) |
Node.js major (installed via NodeSource). |
YARN_VERSION |
stable |
Yarn version (installed only when PACKAGE_MANAGER=yarn). |
PNPM_VERSION |
latest |
pnpm version (installed only when PACKAGE_MANAGER=pnpm). |
USER |
ionic |
Helpful for permissions. |
CORDOVA_VERSION |
13.0.0 |
Cordova CLI version (installed globally in the image). |
IONIC_CLI_VERSION |
7.2.1 |
Ionic CLI version. |
Tip
Check the Android Platform Guide first, make sure you have a matching cordova-android in package.json, and that <preference name="android-targetSdkVersion" value="X" /> in config.xml matches ANDROID_PLATFORMS_VERSION.
docker build . \
--build-arg ENV_NAME="${ENV_NAME}" \
--build-arg PACKAGE_ID="${PACKAGE_ID}" \
--build-arg PLATFORM=${platform} \
--build-arg VERSION="${version}" \
-f ./Dockerfile \
-t app-build| Argument | Meaning |
|---|---|
PACKAGE_ID |
The bundle id you use for your app (injected into config.xml). |
ENV_NAME |
prod or dev, depending on what your environment files are called inside the environments folder. |
PLATFORM |
ios, android, or both via all. |
VERSION |
Optional override for the version specified inside config.xml. See Dockerfile and uncomment the line that sets it. |
PACKAGE_TYPE |
Android artifact type β bundle (.aab for Google Play, default) or apk (installable on a device). Overrides packageType in build.json. |
Android:
docker run --user root:root --privileged=true -v ./build-output:/app/mount:Z --rm --entrypoint cp app-build -r ./output/android /app/mountiOS (can only be prepared on Linux, never compiled β finish on macOS; run pod install there if you use firebasex):
docker run --user root:root --privileged=true -v ./build-output:/app/mount:Z --rm --entrypoint cp app-build -r ./output/ios /app/mount
cd ./build-output/ios && pod repo update && pod installThere is a build-mobile.sh file if you want to run all these steps from a shell (you can comment out the first part later).
The Android build is copied to build-output/android. By default it produces a signed Android App Bundle (the file you upload to the Google Play Console):
build-output/android/app/build/outputs/bundle/release/app-release.aab
The intermediary-bundle.aab under .../intermediates/ is a Gradle scratch file β ignore it.
Verify the artifact is signed:
jarsigner -verify build-output/android/app/build/outputs/bundle/release/app-release.aab
# -> "jar verified."An .aab cannot be installed on a device directly. To get an installable APK (e.g. for sideload testing), build with PACKAGE_TYPE=apk:
PACKAGE_TYPE=apk ./build-mobile.sh androidThe APK then lands under build-output/android/app/build/outputs/apk/release/. Alternatively, generate APKs from an existing bundle with bundletool:
bundletool build-apks --mode=universal \
--bundle=app-release.aab --output=app.apks \
--ks=keys/android.jks --ks-key-alias=alias_nameWarning
Signing key: the demo signs with the committed keys/android.jks (passwords Changeit in build.json). For a real app, generate your own keystore, keep it out of source control, and supply the passwords via secrets/environment β an app signed with the demo key can never be updated on Play by you.
.github/workflows/build.yml runs on push/PR and:
- checks the
example-appDocker copies (Dockerfile,app-builder.Dockerfile) haven't drifted from the root files, - lints, builds and unit-tests the demo app, and
- builds the toolchain image and the demo Android app end-to-end.
.github/dependabot.yml keeps the demo's npm dependencies, the Docker base images, and the GitHub Actions up to date.
Good Luck π§‘