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 Cordova? See the sibling project ionic-cordova-docker. This repo is the Capacitor port of it and defaults to pnpm instead of npm β the same pipeline, 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) and a light per-app build on top of it.
- π Signed Android releases out of the box via Gradle's injected signing properties β the generated
android/project needs no hand-edits. - π iOS prepared on Linux β
cap syncgenerates the Xcode workspace and runspod install; 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 native projects every build β
android/andios/are generated at build time (not committed), so the build is stateless andappIdis 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.
Capacitor replaces Cordova's config.xml with capacitor.config.ts. Because the native projects are generated fresh inside Docker on every build, the Dockerfile injects your PACKAGE_ID into appId before cap add runs β appId becomes the Android namespace/applicationId and the iOS bundle id.
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"]:::img
end
subgraph APP["Dockerfile Β· per-app build"]
C["ng build β www/"]:::step --> D["cap add + cap sync"]:::step --> E["Gradle / CocoaPods"]:::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:#119EFF,stroke:#0b6fb3,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 + Angular/Ionic CLIs). Build it once and reuse it. Capacitor's own CLI is a project devDependency, so it is not installed globally here. |
Dockerfile |
FROM app-builder β copies your app in, builds the Angular web app, then generates + builds the native project with Capacitor. The per-app build. |
scripts/build-capacitor.sh |
The build helper the Dockerfile runs inside the image: builds the web app, runs cap add / cap sync, and produces a signed Android release with Gradle (or prepares the iOS Xcode project). |
build-mobile.sh |
Convenience wrapper that builds both images and copies the artifact out. |
example-app/ |
A small Angular 22 + Ionic v9 (zoneless) + Capacitor 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, app-builder.Dockerfile and scripts/build-capacitor.sh 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 + Capacitor 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. - Capacitor 8 (
@capacitor/core,@capacitor/android,@capacitor/ios) with@capacitor/cliand@capacitor/assetsas devDependencies, plus the@capacitor/{status-bar,keyboard,device,splash-screen}plugins. The About page reads@capacitor/devicelive, so you can see at a glance which flavour/platform you are running. - pnpm is the default package manager (the Cordova sibling defaults to npm β this is the "different way"). An
.npmrcsetsnode-linker=hoistedso pnpm'snode_modulesis flat enough for Capacitor to discover each plugin's nativeandroid/andios/folders duringcap sync. - TypeScript 6, Vitest (jsdom) for unit tests, angular-eslint for linting.
- The toolchain base image builds on Ubuntu 26.04.
pnpm run build emits a flat www/ (configured as webDir in capacitor.config.ts, so cap sync copies it into the native projects), 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.
The Android release is signed with Gradle's injected signing properties (-Pandroid.injected.signing.*) read from keystore.properties (Capacitor's analogue of Cordova's build.json).
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=pnpm \
--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. The generated Capacitor android/ project runs its own ./gradlew wrapper; the system Gradle mainly warms the wrapper's distribution cache (stay on Gradle 8.x for the AGP 8.x plugin). |
JAVA_VERSION |
21 (LTS) |
Capacitor 8's Android template uses AGP 8.x, which runs on JDK 21 with Gradle 8.x. JDK 25 would need AGP 9 / Gradle 9.1+. |
ANDROID_PLATFORMS_VERSION |
36 |
Android platform (compile/target SDK) to install. |
ANDROID_BUILD_TOOLS_VERSION |
35.0.0 |
The build-tools version Capacitor 8's Android Gradle Plugin pins β even though it compiles against platform 36. (build-tools and compile SDK are decoupled; AGP dictates build-tools.) |
ANDROID_SDK_TOOLS_VERSION |
14742923 |
Android command-line tools build number. |
PACKAGE_MANAGER |
npm |
npm, yarn, or pnpm. The demo's build-mobile.sh passes 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 β 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. |
IONIC_CLI_VERSION |
7.2.1 |
Ionic CLI version (optional; Capacitor is driven via npx cap / pnpm exec cap). |
Tip
Check the Capacitor Android docs first, keep @capacitor/android in package.json current, and make sure the generated project's compile/target SDK matches ANDROID_PLATFORMS_VERSION.
docker build . \
--build-arg ENV_NAME="${ENV_NAME}" \
--build-arg PACKAGE_ID="${PACKAGE_ID}" \
--build-arg PACKAGE_MANAGER=pnpm \
--build-arg PLATFORM=${platform} \
--build-arg VERSION="${version}" \
-f ./Dockerfile \
-t app-build| Argument | Meaning |
|---|---|
PACKAGE_ID |
The bundle id for your app β injected into appId in capacitor.config.ts before the native project is generated. |
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 app version (read from package.json). 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). Selects the Gradle task (bundleRelease vs assembleRelease). |
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):
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/App && 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
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 keystore.properties). 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-apptemplate copies (Dockerfile,app-builder.Dockerfile,scripts/build-capacitor.sh) haven't drifted from the root files, - lints, builds and unit-tests the demo app with pnpm, and
- builds the toolchain image and the demo Android app end-to-end.
.github/dependabot.yml keeps the demo's dependencies (it reads pnpm-lock.yaml), the Docker base images, and the GitHub Actions up to date.
Good Luck π§‘