Configuration is the bridge between your agent code and the world it runs in. Where your agent gets its API keys, which model to use, how long to wait before timing out, what region to operate in, and a hundred other knobs are questions configuration must answer. The Twelve-Factor App methodology—adopted industry-wide—mandates that configuration live in the environment, not in code. ADK Java embraces this principle with environment-driven builders, typed config helpers that fail fast on missing values, validation at startup, and support for profiles, external configuration services, and runtime updates. This article covers the full toolkit: how to read environment variables safely, how to validate configuration before the agent runs, how to manage secrets, how to tune per environment, and the common pitfalls that trip up teams building production agents.
The 12-factor config principle
The Twelve-Factor App methodology mandates that configuration be stored in the environment, not in code or config files bundled with the application. This principle keeps secrets out of version control, enables environment-specific behavior without code changes, and makes deployments across dev, staging, and production repeatable and safe. ADK Java embraces this by making environment resolution a first-class concept: builders accept env-keyed defaults, typed config helpers enforce required keys, and profile-aware loading merges environment-specific overrides.
The core idea is simple but critical: your code should never ask ‘where do I read this value?’ Instead, it should declare ‘I need this value, optionally with this fallback,’ and the configuration layer handles sourcing from the environment, defaulting, and validation. This separation keeps the business logic focused and the deployment pipeline clean.
Env-driven builder pattern
The builder pattern is the idiomatic way to construct LLM clients and agent configurations in ADK Java. Instead of passing secrets or model names as strings in constructors, use helper functions to read from environment variables and apply defaults:
BaseLLM llm = GeminiLLM.builder()
.apiKey(env("GEMINI_API_KEY"))
.model(env("GEMINI_MODEL", "gemini-2.5-flash"))
.location(env("GEMINI_LOCATION", "us-central1"))
.build();The env(key) function reads from System.getenv() and throws if missing; env(key, default) returns the default if the key is absent. This pattern is composable: each builder call can source its own env key, and you can chain builders to construct complex agent hierarchies. The benefit is that your source code is completely environment-agnostic—swapping GEMINI_API_KEY in the environment changes which account the agent uses without touching Java code.
Typed config helpers and fail-fast
Wrap System.getenv with typed helper functions that enforce requirements at startup. Fail fast—before the agent runs—if a required key is missing or malformed. This turns silent configuration bugs into loud, debuggable startup errors.
public class Config {
public static String getRequired(String key) {
String value = System.getenv(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Required env var missing: " + key);
}
return value;
}
public static String getOptional(String key, String defaultValue) {
String value = System.getenv(key);
return (value == null || value.isBlank()) ? defaultValue : value;
}
public static int getInt(String key, int defaultValue) {
String value = System.getenv(key);
if (value == null || value.isBlank()) return defaultValue;
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new IllegalStateException("Invalid int for " + key + ": " + value, e);
}
}
}
// Usage
String apiKey = Config.getRequired("GEMINI_API_KEY");
int timeout = Config.getInt("REQUEST_TIMEOUT_MS", 5000);The typed helpers catch parsing errors early—an agent configured with a malformed timeout is rejected at startup, not when the first request hangs. This is the difference between a deployment that works and one that silently breaks in production.
Configuration profiles
Enterprise deployments require different settings for local development, CI/CD, staging, and production. Rather than maintaining separate code paths, use configuration profiles: different YAML or .env files per environment, merged at startup.
# config/application.yaml (defaults)
models:
primary: "gemini-2.0-flash"
fallback: "gemini-1.5-pro"
timeouts:
model_call_ms: 5000
tool_timeout_ms: 3000
# config/application-dev.yaml (local overrides)
models:
primary: "gemini-2.0-flash" # same for fast feedback
timeouts:
model_call_ms: 30000 # be lenient locally
# config/application-prod.yaml (production overrides)
models:
fallback: "gemini-1.5-flash" # cheaper fallback
timeouts:
model_call_ms: 8000
tool_timeout_ms: 5000At startup, read the active profile from DEPLOYMENT_ENV=dev|staging|prod, merge the base configuration with the profile-specific overrides, and validate the result. This lets each environment tune its own trade-offs (latency, cost, safety thresholds) without touching code.
Environment variable precedence and resolution
Configuration values can come from multiple sources, and order matters. A sensible precedence is: command-line flags override environment variables, which override config files, which override hardcoded defaults. ADK Java does not dictate the order, but you should establish one and document it clearly so your team knows where a value came from when debugging.
A typical hierarchy is:
- System properties (
-Dkey=valueon the JVM command line) - Environment variables (
export GEMINI_API_KEY=...) - Configuration files in the classpath (e.g.,
application.yaml) - Hardcoded defaults in the application code
Flatten this into a single lookup: write a function that checks each source in order and returns the first non-empty value found. This makes the resolution transparent and auditable, and it lets operators override deeply-nested settings via environment variables without touching YAML files.
Secrets management: API keys and credentials
API keys, database passwords, and service credentials are the crown jewels. They must never be checked into version control, logged, or dumped into error messages. The best practice is to store secrets in a dedicated secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager, Google Secret Manager) and fetch them at startup or on-demand.
public class SecretConfig {
public static String getSecret(String name) {
// For local dev: read from .env or System.getenv()
// For production: fetch from Secret Manager via client library
if (System.getenv("DEPLOYMENT_ENV").equals("local")) {
return System.getenv(name);
}
// Production: use Google Secret Manager or similar
return secretManagerClient.accessSecret(name).getPayload().getData().toStringUtf8();
}
}
// Usage: fetch once at boot, never log it
String apiKey = SecretConfig.getSecret("gemini-api-key");
BaseLLM llm = GeminiLLM.builder().apiKey(apiKey).build();Where the secret comes from depends on your deployment: local dev can use .env files with git-ignored entries; Docker/K8s deployments use mounted secrets or environment variables injected by the orchestrator; cloud-native setups fetch from a secrets service. The code is the same: one interface, multiple backends.
Configuration validation and contract checking
Not all configurations are valid, even if all required keys are present. A model name might be supported by the API client but not available in your region; a timeout might be configured but lower than the minimum the backend accepts. Validate the entire configuration object at startup, before the agent runs, so errors are caught early and reported clearly.
public class AgentConfig {
private final String model;
private final long timeoutMs;
private final String location;
public AgentConfig(String model, long timeoutMs, String location) {
this.model = validate(model, "model");
this.timeoutMs = validateTimeout(timeoutMs);
this.location = validate(location, "location");
}
private String validate(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Invalid " + field + ": " + value);
}
return value;
}
private long validateTimeout(long ms) {
if (ms < 1000 || ms > 120000) {
throw new IllegalArgumentException("Timeout must be 1s-120s, got " + ms + "ms");
}
return ms;
}
}This investment pays back every time a typo or bad environment variable would have caused subtle production failures. Validation is defensive programming done upfront.
External configuration sources
For larger systems, configuration often lives outside the application: in a remote config service, a database, or a dedicated configuration management tool. ADK Java agents can fetch their configuration at startup and cache it, falling back to environment variables if the remote source is unreachable.
public class RemoteConfigLoader {
public static AgentConfig loadConfig(String agentId) {
try {
// Fetch from a config service (e.g., Spring Cloud Config, Consul)
ConfigResponse resp = configService.getAgentConfig(agentId);
return new AgentConfig(
resp.getModel(),
resp.getTimeoutMs(),
resp.getLocation()
);
} catch (Exception e) {
// Fallback to environment
log.warn("Remote config unavailable, falling back to env", e);
return new AgentConfig(
Config.getRequired("LLM_MODEL"),
Config.getLong("MODEL_TIMEOUT_MS", 5000),
Config.getRequired("MODEL_LOCATION")
);
}
}
}This pattern is useful when multiple agent instances need coordinated configuration, or when you want to update settings without redeploying. The tradeoff is added latency and potential network failure; cache aggressively and have a clear fallback strategy.
Runtime configuration updates and hot reload
Some settings can change after the agent starts: rate limits, feature flags, model routing rules. Rather than requiring a restart, watch for configuration changes and reload them in-flight. This is complex to get right—you must handle in-progress requests safely, avoid race conditions, and maintain backwards compatibility—so use it only for settings that truly need to change at runtime.
public class RuntimeConfig {
private volatile AgentConfig config;
public RuntimeConfig(AgentConfig initial) {
this.config = initial;
}
public AgentConfig get() {
return config;
}
public void reloadIfChanged() {
AgentConfig newConfig = loadCurrentConfig();
if (!newConfig.equals(config)) {
log.info("Reloading configuration", Map.of(
"old_model", config.getModel(),
"new_model", newConfig.getModel()
));
this.config = newConfig;
}
}
}
// In the agent's main loop or a scheduled task:
scheduledExecutor.scheduleAtFixedRate(runtimeConfig::reloadIfChanged, 1, 1, TimeUnit.MINUTE);Use volatile fields and careful synchronization to avoid visible inconsistencies. Better yet, limit hot reload to truly dynamic settings (flags, limits) and restart the JVM for structural changes (model endpoints, credential rotation).
Configuration as code with fluent builders
For complex agent hierarchies, writing configuration in code can be cleaner than external files. ADK Java's builder pattern is the foundation; layering fluent helpers on top makes configuration readable and type-safe.
public class AgentBuilder {
private String name;
private String model;
private List<Tool> tools;
public AgentBuilder withName(String name) { this.name = name; return this; }
public AgentBuilder withModel(String model) { this.model = model; return this; }
public AgentBuilder withTool(Tool tool) {
if (tools == null) tools = new ArrayList<>();
tools.add(tool);
return this;
}
public LlmAgent build() {
return LlmAgent.builder()
.name(name)
.model(model)
.tools(tools)
.instruction(buildInstruction())
.build();
}
}
// Usage:
LlmAgent agent = new AgentBuilder()
.withName("support_router")
.withModel(env("ROUTER_MODEL", "gemini-2.0-flash"))
.withTool(lookupOrderTool)
.withTool(transferToAgentTool)
.build();Fluent builders are more verbose than YAML, but they bring compile-time type checking and IDE support. Use builders for the application's core, and YAML for deployment overrides.
Multitenancy and tenant-scoped configuration
If your agent serves multiple tenants, configuration must be tenant-aware. Each tenant may have different models, API keys, tool permissions, or safety thresholds. Store tenant configuration keyed by tenant ID and load it per-request, or cache it with careful invalidation.
public class TenantConfigCache {
private final Map<String, AgentConfig> cache = new ConcurrentHashMap<>();
private final TenantConfigService service;
public AgentConfig getConfig(String tenantId) {
return cache.computeIfAbsent(tenantId, id -> {
AgentConfig cfg = service.loadConfig(id);
if (cfg == null) {
cfg = AgentConfig.default(); // fallback to defaults
}
return cfg;
});
}
public void invalidate(String tenantId) {
cache.remove(tenantId);
}
}
// Per-request:
AgentConfig config = tenantConfigCache.getConfig(tenantId);
LlmAgent agent = buildAgent(config);The cache reduces load on the config service; invalidation keeps it fresh when a tenant updates their settings. For very high request volume, consider separating read and write paths: cache reads aggressively, but queue configuration updates and process them asynchronously.
Testing configuration assumptions
Configuration errors are often caught late—in staging or production. Write tests that verify the configuration is complete, valid, and matches your expectations. This is cheaper than debugging in production.
@Test
public void testConfigurationIsValid() {
// Verify all required env vars are present
assertTrue(System.getenv("GEMINI_API_KEY") != null);
assertTrue(System.getenv("MODEL_LOCATION") != null);
// Verify parsed values are sensible
int timeout = Config.getInt("REQUEST_TIMEOUT_MS", 5000);
assertTrue(timeout > 1000 && timeout < 120000, "Timeout out of range");
// Verify the agent builds and does not crash
LlmAgent agent = new AgentBuilder()
.withName("test")
.withModel(Config.getRequired("GEMINI_MODEL"))
.build();
assertNotNull(agent);
}
@Test
public void testFallbackDefaults() {
// Verify that missing optional env vars use sensible defaults
String model = Config.getOptional("CUSTOM_MODEL", "gemini-2.0-flash");
assertEquals("gemini-2.0-flash", model);
}
@Test(expected = IllegalStateException.class)
public void testMissingRequiredEnvVarThrows() {
// Temporarily unset a required var and verify it fails fast
String saved = System.getenv("GEMINI_API_KEY");
// In a real test, use a mock environment or test fixture
Config.getRequired("NONEXISTENT_KEY"); // should throw
}These tests catch configuration bugs before they reach production. Run them in CI/CD with a sample environment, and as part of deployment checks.
Common configuration pitfalls
Configuration mistakes are surprisingly common. Watch for these traps:
| Pitfall | Symptom | Fix |
|---|---|---|
| Hardcoded secrets in code | Secrets exposed in git history, logs | Always use env vars or secrets manager; never commit keys |
| Missing required env var | NullPointerException at runtime | Fail-fast with Config.getRequired(); test configuration |
| Typo in env var name | Silent fallback to wrong default | Use constant strings; log which vars are loaded at startup |
| Profile not activated | Local config used in production | Log active profile at startup; validate it matches DEPLOYMENT_ENV |
| Stale cache after update | New config not visible to running agents | Set appropriate cache TTL; provide manual invalidation |
| Configuration not validated | Agent starts with garbage config, fails later | Validate all config at startup; unit-test Config class |
The theme is the same: fail fast, validate early, log openly. Configuration bugs are invisible until they strike, so make them loud and obvious during startup and testing.
Best practices summary
Environment and configuration management is not glamorous, but it is foundational. Follow these practices: keep secrets out of code and version control, using environment variables or a secrets manager; declare configuration requirements with typed helpers that fail fast on missing or malformed values; validate the entire configuration object at startup; use profiles or external configuration services to adapt behavior per environment; test configuration assumptions before deployment; and log the active configuration (without revealing secrets) so debugging is easier.
When these systems are in place, deploying an agent across environments is boring and safe—the kind of boring that wins production stability. The care you invest in configuration upfront pays continuous dividends in operational reliability and team velocity.
System.getenv() with typed helper functions that fail fast on missing or malformed values. Validate the entire configuration object at startup, before the agent runs. Use configuration profiles (dev, staging, prod) and external configuration services for environment-specific tuning. Store secrets in a dedicated secrets manager, never in version control. Support runtime updates only for truly dynamic settings. Write tests that verify configuration assumptions. Watch for the common pitfalls: hardcoded secrets, missing required vars, typos, unchecked caches, and skipped validation. Done well, configuration becomes a transparent, auditable, boring plumbing detail—the best kind.