From Script to Signed APK: A Production Pipeline for Python Android Apps
Posted on Sun 20 September 2026 in Article
Python is not a native Android language, so shipping a Python app as an APK takes more deliberate engineering than shipping a Python wheel or Docker image. A professional pipeline needs three things: a toolchain suited to your app, a reproducible build, and a release process (signing, versioning, verification) that runs without manual steps. This article covers each.
What an APK from Python actually contains
An APK is a signed ZIP archive. When you package a Python app, the toolchain typically bundles:
-
A cross-compiled CPython interpreter for each target CPU architecture (ABI), such as arm64-v8a and x86_64
-
Your Python source and dependencies, either as .py/.pyc files or in a compressed asset bundle
-
Native libraries your dependencies need (SDL2 for Kivy, for example)
-
A thin Java/Kotlin launcher that starts the interpreter and hands control to your code
-
The AndroidManifest.xml, resources, and icons
Two consequences follow. APKs are large, because you ship an interpreter. And your Python code is not protected: anyone can unzip the APK and read it, so treat the client as untrusted.
Choosing a toolchain
-
Buildozer / python-for-android: Best for Kivy apps, and for Python apps that need fine control over the build. It runs on Linux and macOS (WSL2 on Windows) and is driven by a single buildozer.spec file.
-
BeeWare Briefcase: Best for apps that use native UI widgets via Toga, with one workflow across desktop and mobile. It uses Gradle and Chaquopy under the hood.
-
Flet (flet build apk): Best for teams that want Flutter-rendered UIs written in Python. It requires the Flutter toolchain, which Flet can manage for you.
-
Chaquopy: Best for existing native Kotlin/Java apps that need to embed Python. It is a Gradle plugin, so Python lives inside a normal Android Studio project.
The decision is mostly about your UI. If the app is Python-first, choose the framework (Kivy, Toga, Flet) and use its toolchain. If it is a native app that calls Python for logic, such as data processing or ML inference, Chaquopy is usually the cleanest fit.
The rest of this article uses Buildozer for concrete examples. The pipeline principles apply to any of them.
Structure the project for mobile
Keep business logic independent of the UI and the Android runtime:
myapp/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── core/ # pure Python: testable on any machine
│ ├── services/ # storage, networking
│ └── ui/ # Kivy/Toga/Flet layer
├── tests/
├── main.py # thin entry point (Buildozer expects this)
├── buildozer.spec
├── requirements.lock
└── .github/workflows/
The core/ package should run and be tested under plain pytest on a laptop. Only the UI layer and a few platform adapters (permissions, file paths, notifications) should touch Android APIs. This keeps the slow "build an APK and test on a device" loop small.
Configuring the build
buildozer init generates a spec file. These are the settings that matter for production:
ini
[app]
title = MyApp
package.name = myapp
package.domain = com.yourcompany
source.dir = .
source.include_exts = py,png,jpg,kv,json
version = 1.4.0
android.numeric_version = 10400
# Pin everything. Unpinned requirements make builds irreproducible.
requirements = python3,kivy==2.3.0,requests==2.32.3,certifi
orientation = portrait
fullscreen = 0
[buildozer]
log_level = 2
# Android targets
android.api = 35
android.minapi = 24
android.archs = arm64-v8a, armeabi-v7a
android.accept_sdk_license = True
android.permissions = INTERNET
android.release_artifact = apk
Key points:
-
Pin versions. Both in requirements and in the toolchain (Buildozer and python-for-android versions). A floating dependency is a build that breaks on a Tuesday for no visible reason.
-
Request minimal permissions. Every permission is a review burden and a privacy liability.
-
android.numeric_version (the Android versionCode) must increase with every release you upload anywhere. Don't maintain it by hand; derive it in CI, for example major10000 + minor100 + patch, or the CI run number.
-
Target API level is driven by store policy. Google Play raises its minimum target API requirement yearly, so check the current requirement before each release cycle.
The native dependency problem
Pure-Python packages are straightforward. Packages with C extensions (NumPy, cryptography, Pillow, and similar) must be cross-compiled for Android. With python-for-android, that requires a recipe for the package. Many popular libraries have one, but not all versions do. With Chaquopy, prebuilt Android wheels are provided for many popular packages. Before committing to a dependency, check that your toolchain supports it. Discovering that a core library can't be built on Android in week six is an expensive surprise.
Reproducible builds with Docker
"Works on my machine" is worse for Android builds than most, since the build depends on the JDK, the Android SDK/NDK, and system libraries. Build in a container so every developer and CI run uses the same environment.
bash
docker run --rm \
--volume "$(pwd)":/home/user/hostcwd \
--volume "$HOME/.buildozer":/home/user/.buildozer \
kivy/buildozer android debug
The second volume caches the downloaded SDK/NDK and compiled recipes, cutting rebuilds from tens of minutes to a few. For a serious project, pin a specific image tag (or build your own image from a Dockerfile in the repo) rather than tracking latest.
Debug versus release builds
-
Debug builds (buildozer android debug) are signed with an auto-generated debug key. Use them for development and testing only. They can't be published to a store.
-
Release builds (buildozer android release) must be signed with your own key.
Key management
Generate a keystore once and protect it as you would a production credential:
bash
keytool -genkeypair -v \
-keystore release.jks \
-alias myapp \
-keyalg RSA -keysize 2048 -validity 10000
Rules that save careers:
-
Never commit the keystore or its passwords. Store the keystore as a base64 secret in your CI system, and keep an offline backup in a password manager or vault.
-
Losing the key can mean losing the ability to update your app. For Google Play, enroll in Play App Signing so Google holds the app signing key and you sign uploads with a replaceable upload key.
-
Use separate keys for internal distribution and store releases if your process allows it.
Manual signing
If your toolchain produces an unsigned APK, the correct order is align first, then sign:
bash
zipalign -p -f 4 app-release-unsigned.apk app-aligned.apk
apksigner sign \
--ks release.jks \
--ks-key-alias myapp \
--out app-release.apk \
app-aligned.apk
apksigner verify --verbose --print-certs app-release.apk
Signing after aligning matters because modern signature schemes cover the whole file, and aligning afterwards would invalidate the signature. Always run apksigner verify as a pipeline step. It takes a second and catches broken releases before users do.
APK versus AAB
An APK is what you install directly on devices: internal testing, enterprise distribution, F-Droid, or your own website. Google Play, however, requires Android App Bundles (.aab) for new apps. Play uses the bundle to generate optimized APKs per device.
If you distribute through both channels, produce both artifacts from the same commit:
-
AAB → Play Console
-
APK → direct downloads and QA
Buildozer's android.release_artifact setting controls which one you get, so a CI job can build them in sequence.
Controlling APK size
Interpreter-bundled apps start large, and users notice. Practical levers:
-
Limit ABIs. Most modern phones are arm64-v8a. Building for fewer architectures cuts size significantly. Add armeabi-v7a only if you need to support older devices, and x86_64 only for emulators.
-
Ship per-ABI APKs for direct distribution rather than one fat APK containing every architecture.
-
Audit dependencies. One convenience import of a large scientific library can add tens of megabytes. Ask whether a lighter alternative or a server-side call would do.
-
Exclude non-runtime files through source.include_exts and source.exclude_dirs, so tests, docs, and raw assets don't ship.
-
Compress assets (images, models) before packaging.
Automating with CI/CD
A release should come from a tagged commit through an automated pipeline, never from someone's laptop. A minimal GitHub Actions outline:
yaml
name: release-apk
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run unit tests
run: |
pip install -r requirements-dev.txt
pytest tests/
- name: Cache Buildozer
uses: actions/cache@v4
with:
path: ~/.buildozer
key: buildozer-${{ hashFiles('buildozer.spec') }}
- name: Build unsigned release
run: |
docker run --rm \
-v "$PWD":/home/user/hostcwd \
-v "$HOME/.buildozer":/home/user/.buildozer \
kivy/buildozer android release
- name: Sign and verify
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
KS_PASS: ${{ secrets.KEYSTORE_PASSWORD }}
run: |
echo "$KEYSTORE_B64" | base64 -d > release.jks
# zipalign + apksigner sign + apksigner verify (see section 6)
- uses: actions/upload-artifact@v4
with:
name: apk
path: bin/*.apk
Adapt the signing step to your artifact names, and make sure the keystore file is deleted or never uploaded as part of the artifact. Beyond this skeleton, mature pipelines typically add:
-
Linting and type checking (ruff, mypy) before the build
-
Automatic version-code generation
-
An install-and-smoke-test step on an emulator
-
Upload to a Play internal testing track or a release page
-
Checksums (SHA-256) published alongside every APK
Testing and debugging
-
Unit-test on the desktop. Most of your suite should never need Android.
-
Test on real devices. Emulators miss vendor-specific behavior around permissions, battery optimization, and file storage.
-
Read the logs:
bash
adb install -r bin/myapp-1.4.0-arm64-v8a-release.apk
adb logcat -s python
Python tracebacks show up in logcat, which is your primary debugging tool on device.
-
Test the release build, not just debug. Signing, minification, and packaging differences can produce bugs that only appear in release.
-
Add crash reporting (Sentry supports Android, for example), because you won't otherwise see failures on users' phones.
Security considerations
-
Assume your code is readable. Compiling to .pyc or obfuscating slows attackers down, but doesn't stop them. Never embed API keys, private tokens, or credentials. Put sensitive logic behind an authenticated server.
-
Use HTTPS only, and validate certificates. Bundle certifi or use the system trust store explicitly, as Python on Android doesn't always find CA certificates by default.
-
Store secrets appropriately. Use the Android Keystore through a platform API for tokens, not plain files in app storage.
-
Keep dependencies patched. Pinned versions need a scheduled review, for example a weekly pip-audit job, so pinning doesn't turn into neglect.
Release checklist
-
All tests pass on the tagged commit
-
version and android.numeric_version incremented
-
Dependencies pinned and audited
-
Release built in the pinned container from a clean checkout
-
Artifact aligned, signed with the production key, and apksigner verify passes
-
Installed and smoke-tested on at least one real device
-
Checksums generated; artifacts archived with the git tag
-
Store metadata and permission declarations reviewed
-
Staged rollout, with crash reporting watched after release
Common pitfalls
-
Unpinned build tooling causing a working project to fail a month later
-
Dependency without an Android build, discovered late
-
Forgetting to bump the version code, so the store or device rejects the update
-
Signing with the debug key and finding out when the store refuses the upload
-
Losing the release keystore
-
Testing only on an emulator and missing device-specific permission and storage behavior
Conclusion
Producing an APK from Python is less about any single command than about the pipeline around it: a toolchain chosen for your UI and dependencies, a containerized and pinned build, protected signing keys, automated verification, and a release process that any team member can run from a tag. Set that up early and Android becomes another routine build target for your project.
Toolchain versions and store policies change often, so check each tool's documentation and the current Google Play requirements before finalizing your pipeline.