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.

Advertisement

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: 5000

At 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:

  1. System properties (-Dkey=value on the JVM command line)
  2. Environment variables (export GEMINI_API_KEY=...)
  3. Configuration files in the classpath (e.g., application.yaml)
  4. 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.

Advertisement

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:

PitfallSymptomFix
Hardcoded secrets in codeSecrets exposed in git history, logsAlways use env vars or secrets manager; never commit keys
Missing required env varNullPointerException at runtimeFail-fast with Config.getRequired(); test configuration
Typo in env var nameSilent fallback to wrong defaultUse constant strings; log which vars are loaded at startup
Profile not activatedLocal config used in productionLog active profile at startup; validate it matches DEPLOYMENT_ENV
Stale cache after updateNew config not visible to running agentsSet appropriate cache TTL; provide manual invalidation
Configuration not validatedAgent starts with garbage config, fails laterValidate 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.

Environment and configuration management is foundational to production reliability. Follow the Twelve-Factor App principle by storing configuration in the environment, not in code. Use env-driven builders that read from 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.