Android Secrets Without Committing Them: Local Config and CI
A practical pattern for keeping Android signing credentials and environment-specific values out of Git while making local and CI builds predictable.
Table of Contents10 sections

A build can be reproducible without making every configuration value public. The useful boundary is simple: commit the configuration contract, not the secret values.
For Android projects, that usually means keeping non-secret defaults and build logic in version control, storing developer-only credentials in an ignored local file, and injecting CI credentials from the CI platform’s secret store. Gradle should then fail early when a required value is missing instead of silently building an unusable artifact.
This pattern works especially well for signing credentials, private service tokens, and environment-specific endpoints that should not be hardcoded into the repository.
Separate configuration into three classes
Before choosing a file format, classify each value.
| Class | Examples | Where it belongs |
|---|---|---|
| Public build configuration | application ID suffix, feature flag default, API base path that is public | tracked Gradle files |
| Local secret | keystore password, key password, developer token | ignored local properties file |
| CI secret | release signing password, deployment token | CI secret store exposed as environment variables |
The distinction matters because not every configuration value is a secret. An API hostname embedded in an APK can generally be extracted from the binary, so hiding it in local.properties does not turn it into a credential. Treat values as secrets only when possession of the value grants authority.
Android’s build documentation explicitly warns against putting release key and keystore passwords directly in build files. It recommends obtaining them from environment variables or a local properties file that is not committed to source control.
Use a dedicated local file for developer secrets
local.properties already has a special role in Android projects and is commonly machine-specific. For project-owned secret configuration, a dedicated ignored file such as keystore.properties or config.properties is often clearer because its purpose is explicit.
For example:
# config.properties - never commit the real file
API_TOKEN=replace-me
KEYSTORE_PATH=/absolute/path/to/release.jks
KEYSTORE_PASSWORD=replace-me
KEY_ALIAS=release
KEY_PASSWORD=replace-me
Add the real file to .gitignore, then commit a safe template:
# config.properties.example
API_TOKEN=
KEYSTORE_PATH=
KEYSTORE_PASSWORD=
KEY_ALIAS=
KEY_PASSWORD=
The example file is important. Without it, a new developer learns about required configuration only after a build fails, usually from an unrelated Gradle error.
Do not put real credentials in the example file, documentation, test fixtures, screenshots, or shell history copied into an issue.
Load local values explicitly in Gradle
A Kotlin DSL build can load a properties file without adding another dependency:
import java.util.Properties
val localConfig = Properties()
val localConfigFile = rootProject.file("config.properties")
if (localConfigFile.exists()) {
localConfigFile.inputStream().use(localConfig::load)
}
fun configValue(name: String): String? =
System.getenv(name)
?.takeIf { it.isNotBlank() }
?: localConfig.getProperty(name)?.takeIf { it.isNotBlank() }
This establishes a useful precedence rule: CI environment variables win, while a local file provides the developer fallback.
Keep that precedence documented. Configuration becomes difficult to debug when the same key can come from five places and nobody knows which one wins.
Fail fast for values required by a release build
A missing credential should not become a mysterious signing or deployment failure twenty minutes later.
Resolve required values close to the configuration that needs them:
fun requiredConfig(name: String): String =
configValue(name)
?: error(
"Missing $name. Set it as an environment variable " +
"or in the ignored config.properties file."
)
Then wire release signing deliberately:
android {
signingConfigs {
create("release") {
storeFile = file(requiredConfig("KEYSTORE_PATH"))
storePassword = requiredConfig("KEYSTORE_PASSWORD")
keyAlias = requiredConfig("KEY_ALIAS")
keyPassword = requiredConfig("KEY_PASSWORD")
}
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
}
There is one practical refinement: do not require release-only secrets while someone is running an unrelated debug task. If configuration is evaluated eagerly, a developer who only wants assembleDebug may be blocked by a missing release keystore.
A small convention can solve this: isolate release signing setup in the release path, or make the configuration provider lazy enough that the value is required only when the relevant task needs it. The exact implementation depends on the Android Gradle Plugin version and project structure, but the rule stays the same: validate secrets when they become required, not globally by accident.
If your project has several flavors or environments, keep their boundaries explicit. The RayLabs guide to Android product flavors explains why variant design should model real distribution boundaries rather than become a pile of Gradle conditionals.
Inject CI secrets without changing the build script
A good CI setup should use the same Gradle contract as a developer machine. The difference is only where the values originate.
Conceptually, the CI job provides:
KEYSTORE_PATH=/runner/work/app/release.jks
KEYSTORE_PASSWORD=***
KEY_ALIAS=release
KEY_PASSWORD=***
The build script still calls configValue("KEYSTORE_PASSWORD"). It does not need a special if (isCI) branch.
The keystore file itself needs separate handling. Common options are a protected CI file secret, an encrypted artifact retrieved during the job, or a base64-encoded secret decoded into a temporary file. Whichever mechanism you use, create the file only for the job, restrict its permissions where possible, and delete it with the ephemeral runner.
Do not print secret values to logs. Avoid debugging statements that dump all environment variables or the full Properties object.
Keep runtime secrets out of the APK
Gradle configuration can protect credentials used during the build, but it cannot safely hide a credential that the app itself must possess at runtime.
For example, this is not a secure secret store:
buildConfigField(
"String",
"PRIVATE_BACKEND_KEY",
"\"${requiredConfig("PRIVATE_BACKEND_KEY")}\""
)
The value becomes part of the application package and should be considered recoverable by someone who can inspect the APK.
If a credential authorizes privileged backend operations, keep it on a server you control and let the mobile app authenticate to that server using an appropriate user or device identity. BuildConfig, resources, native code, and string obfuscation can raise extraction effort, but they do not change the trust boundary.
Use BuildConfig for values that are safe to ship, such as public environment identifiers or non-secret endpoints.
Make configuration failures actionable
A configuration system is only useful if another engineer can recover from a clean clone.
A practical checklist is:
.gitignoreexcludes real local secret files and keystores.- A committed
.examplefile lists required keys without values. - The README explains where local values go and which CI variables are required.
- Gradle reports the missing key by name without printing its value.
- CI uses protected secret storage rather than repository variables for credentials.
- Release jobs verify that signing material exists before starting expensive build steps.
- Secret rotation does not require a source-code change.
This is also a good place to add a lightweight repository check that rejects known secret filenames or accidental keystore files. The check is not a substitute for secret scanning, but it catches the simplest mistakes before review.
Avoid common configuration traps
Committing a private file once and deleting it later. Removing the file in a later commit does not remove it from Git history. Treat an exposed credential as compromised and rotate it.
Using one shared developer secret forever. Shared credentials make rotation and attribution harder. Prefer per-environment or per-user credentials when the upstream service supports them.
Calling every value a secret. Overprotecting harmless configuration creates unnecessary setup friction and encourages developers to bypass the system.
Duplicating configuration logic between local and CI builds. Two code paths drift. Prefer one Gradle lookup contract with different providers.
Assuming obfuscation protects embedded credentials. R8 can make code harder to inspect, but a credential required by the app still crosses the client trust boundary.
A configuration contract that scales
The durable pattern is not a particular filename. It is a boundary:
- source control defines which configuration keys exist and how they are consumed;
- developer machines provide local secret values without committing them;
- CI injects protected values through the same interface;
- Gradle rejects incomplete release configuration with useful errors;
- runtime privileged credentials stay off the client entirely.
That separation keeps onboarding predictable, CI reproducible, and credential rotation independent from application code. It also makes future migrations easier because the application depends on a small configuration contract instead of the storage mechanism behind it.
Continue Exploring
You Might Also Like

Android App Updates: Play vs Managed Devices
Choose the right Android update path for Play-distributed apps, enterprise-managed fleets, and true OS OTA updates without mixing three different mechanisms.

Supporting Legacy Android Devices Without Freezing a Modern Codebase
A practical compatibility strategy for teams that must keep Android 6 and 7 devices working while the application, dependencies, and target SDK continue to move forward.

How to Design Forced Android Update Policies Without Bricking Devices
A practical policy model for mandatory Android updates that separates backend compatibility from installation, handles offline devices, stages rollouts, and preserves recovery paths.