3. Container Configuration¶
Decision Control Tower ships as a single OCI-compatible container image that bundles the Spring Boot backend and the static frontend bundle. The same image is used across all deployment targets — local Container, Kubernetes, OpenShift — and is configured exclusively through environment variables. This chapter documents the runtime requirements, the full configuration surface, and how to operate the container in each common scenario.
Image¶
| Property | Value |
|---|---|
| Base | eclipse-temurin:17-alpine3.23 (DHI variant) |
| Architecture | linux/amd64 |
| Default port | 8080 (HTTP) |
| User | nonroot (UID/GID set by base image) |
| Entrypoint | java -server -jar /app/tower-core.jar |
Default tag scheme: <registry>/aletyx/decision-control-tower:<version>. Customers receiving this image through Aletyx distribution will be issued the registry endpoint and pull credentials separately.
The image is signed using cosign. Validate the signature before promoting an image into a production registry; the signing key is published alongside each release.
Runtime requirements¶
| Resource | Minimum | Recommended |
|---|---|---|
| Container runtime | Docker 20.10+ or Podman 4.x | latest stable |
| OCI compatibility | image manifest schema v2 | — |
| CPU | 0.5 cores | 1–2 cores per replica |
| Memory | 2.5 GiB | 3 GiB |
| Filesystem | read-only root supported | read-only root + writable /tmp and /app/config |
| Outbound network | reach OIDC provider, database, sidecar | same |
The image's JAVA_TOOL_OPTIONS is set to use -Xms2g -Xmx2g -XX:MaxMetaspaceSize=256m. The container must be allocated enough memory for the heap, metaspace, code cache, native overhead and Tomcat threads. Allocating less than 2.5 GiB of memory will cause OOMKilled events under any GC pressure.
If you need a smaller footprint (for example, an evaluation deployment on a constrained host), override JAVA_TOOL_OPTIONS to set a smaller heap:
JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75 -Xms768m -Xmx1024m -XX:MaxMetaspaceSize=256m"
Networking and ports¶
| Direction | Port / target | Purpose |
|---|---|---|
| Inbound | 8080/tcp (configurable via BACKEND_PORT) |
HTTP traffic from the reverse proxy / ingress / browser |
| Outbound | 5432/tcp to PostgreSQL |
Tower's own database |
| Outbound | sidecar HTTP port (default 8080/tcp) |
Tower → Sidecar API |
| Outbound | OIDC provider HTTPS | Discovery, token endpoint, JWK keys |
| Outbound | each registered Decision Control instance | Health probe loop |
Tower binds to 0.0.0.0 on BACKEND_PORT by default. There is no internal admin port — /actuator/health is exposed on the same port and is the only Actuator endpoint reachable without authentication.
Filesystem¶
| Path | Purpose | Writable? |
|---|---|---|
/app/tower-core.jar |
Application JAR | no |
/app/config/ |
Mount point for static configuration files (e.g., environments.json) |
no (mount as read-only) |
/tmp |
Spring Boot temporary upload buffers, Tomcat work directory | yes |
A read-only root filesystem is supported provided /tmp is mounted as a writable tmpfs or emptyDir (Kubernetes). In OpenShift, the default restricted-v2 SCC permits this configuration without modification.
Configuration surface (environment variables)¶
Variables are grouped by concern. All variables are optional unless marked required; defaults are shown for reference.
Server¶
| Variable | Default | Description |
|---|---|---|
APP_HOST |
http://localhost:8080 |
Public URL of the application. Used by the frontend to build OIDC redirect URIs and by the backend for absolute links in emails / notifications. Must match the externally-reachable hostname. |
APP_PORT |
8080 |
Exposed container port (informational only). |
BACKEND_PORT |
8080 |
Spring Boot bind port. Must match APP_PORT and the port mapping. |
Database¶
See chapter 2 for full details.
| Variable | Required | Default | Description |
|---|---|---|---|
ALETYX_DC_TOWER_DB_URL |
yes | jdbc:postgresql://localhost:5434/decision_tower_db |
JDBC URL |
ALETYX_DC_TOWER_DB_USERNAME |
yes | tower |
Database username |
ALETYX_DC_TOWER_DB_PASSWORD |
yes | tower |
Database password (provide via secret manager) |
OIDC / Security¶
See chapter 1 for provider-specific details.
| Variable | Default | Description |
|---|---|---|
ALETYX_OIDC_PROVIDER |
entra |
entra or keycloak |
ALETYX_OIDC_CLIENT_ID |
decision-tower |
OAuth2 client ID |
ALETYX_OIDC_SCOPES |
openid,profile,email |
Comma-separated scopes |
ALETYX_OIDC_ROLE_CLAIM_PATH |
roles |
JSON path to the roles claim in the JWT |
ALETYX_OIDC_ENTRA_TENANT_ID |
(empty) | Microsoft Entra tenant ID |
ALETYX_OIDC_ENTRA_AUDIENCE |
api://decision-tower |
Token audience expected by Tower |
ALETYX_OIDC_ENTRA_CLIENT_SECRET |
(empty) | Confidential client secret |
ALETYX_OIDC_KEYCLOAK_ISSUER_URI |
(empty) | Keycloak realm issuer URI |
ALETYX_OIDC_KEYCLOAK_JWK_SET_URI |
(empty) | Keycloak JWK set URI |
ALETYX_OIDC_KEYCLOAK_CLIENT_ID |
(empty) | Keycloak client ID |
ALETYX_OIDC_KEYCLOAK_CLIENT_SECRET |
(empty) | Keycloak confidential client secret |
ALETYX_OIDC_TENANT_CLAIM_NAME |
tenant_id |
JWT tenant claim; set to tid for Entra |
ALETYX_OIDC_TENANT_DEFAULT_ID |
0 |
Tenant ID when the token has no tenant claim (e.g. aletyx) |
ALETYX_OIDC_CALLBACK_SECRET |
(empty) | Shared secret for Sidecar → Tower callbacks |
ALETYX_OIDC_INTERNAL_SECRET |
(empty) | Shared secret for Tower → DC server-to-server calls (must match DC) |
ALETYX_OIDC_TOKENCLIENT_CONNECT_TIMEOUT |
5s |
OIDC token-client TCP connect timeout (Duration) |
ALETYX_OIDC_TOKENCLIENT_READ_TIMEOUT |
10s |
OIDC token-client read timeout (Duration) |
ALETYX_OIDC_RBAC_ENABLED |
false |
Master toggle for URL-level RBAC |
ALETYX_OIDC_RBAC_ROLES_ALLOWED_* |
(empty) | Per-action role allow-lists — see Identity & Access Management |
Resilience (Feign / circuit breaker / retry)¶
Tunes the outbound HTTP client (Feign) and the resilience4j circuit breaker and retry policy applied to all Tower → Sidecar / Data Index / Decision Control calls. The defaults below are sensible for most deployments — adjust only when an upstream service has known latency characteristics.
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_FEIGN_CONNECT_TIMEOUT |
5000 |
TCP connect timeout (milliseconds) for outbound Feign calls. |
ALETYX_DC_TOWER_FEIGN_READ_TIMEOUT |
15000 |
Socket read timeout (milliseconds) for outbound Feign calls. |
ALETYX_DC_TOWER_CB_WINDOW |
10 |
Circuit breaker sliding window size (number of recent calls evaluated). |
ALETYX_DC_TOWER_CB_MIN_CALLS |
5 |
Minimum number of calls in the window before the failure/slow rates are evaluated. |
ALETYX_DC_TOWER_CB_FAILURE_RATE |
50 |
Failure rate threshold (percentage). When the failure rate is at or above this value, the circuit opens. |
ALETYX_DC_TOWER_CB_WAIT |
30s |
Time the circuit stays open before transitioning to half-open (Duration). |
ALETYX_DC_TOWER_CB_SLOW_THRESHOLD |
5s |
Call duration considered "slow" for the slow-call rate (Duration). |
ALETYX_DC_TOWER_CB_SLOW_RATE |
80 |
Slow-call rate threshold (percentage). When the slow-call rate is at or above this value, the circuit opens. |
ALETYX_DC_TOWER_RETRY_ATTEMPTS |
2 |
Maximum retry attempts on transient failures (connection timeout, retryable Feign exceptions). |
ALETYX_DC_TOWER_RETRY_WAIT |
500ms |
Base wait between retries (Duration); exponential backoff (×2) is applied on each subsequent attempt. |
Sidecar and Data Index¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_SIDECAR_URL |
http://localhost:8080 |
Tower → Sidecar HTTP base URL |
ALETYX_DC_TOWER_DATA_INDEX_URL |
http://localhost:8080 |
Tower → Data Index HTTP base URL (typically the same host as sidecar) |
ALETYX_OIDC_CALLBACK_SECRET |
(empty) | Shared secret used to authenticate Sidecar → Tower callbacks. Must match the value on the Sidecar. |
When deploying both Tower and Sidecar in the same Kubernetes cluster, set these to the in-cluster Service hostname (e.g. http://decision-control-tower-sidecar.<namespace>.svc.cluster.local) — never the public Cloudflare / load-balancer hostname.
Environments seed¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_ENVIRONMENTS_FILE |
/app/config/environments.json |
Path to the JSON file used to seed and upsert the environments table on startup |
ALETYX_DC_TOWER_SEED_TENANT_ID |
falls back to ALETYX_OIDC_ENTRA_TENANT_ID |
Tenant ID stamped on rows seeded by the file |
The file format and behavior are documented in chapter 4 (sidecar / static-config section).
Email Notification¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_EMAIL_ENABLED |
true |
Master switch. When false, no notification email is sent regardless of the other SMTP settings. |
ALETYX_DC_TOWER_EMAIL_FROM |
notifier@example.com |
Address used in the From: header of every notification email. The image ships with a placeholder default; override to your organization's notification sender. |
ALETYX_DC_TOWER_SMTP_HOST |
smtp.example.com |
SMTP server hostname. The image ships with a placeholder default; override to your organization's SMTP relay. |
ALETYX_DC_TOWER_SMTP_PORT |
587 |
SMTP server port (587 for STARTTLS, 465 for SMTPS). |
ALETYX_DC_TOWER_SMTP_USERNAME |
notifier@example.com |
Username used to authenticate against the SMTP server. The image ships with a placeholder default; override to match your SMTP credentials. |
ALETYX_DC_TOWER_SMTP_PASSWORD |
(empty) | Password used to authenticate against the SMTP server. Provide via a Kubernetes Secret (or equivalent) — never a ConfigMap. |
ALETYX_DC_TOWER_GROUPS_CONFIG_PATH |
./config/notification-groups.json |
Path (inside the container) to the JSON file mapping notification groups to recipient email addresses. Must be mounted. |
ALETYX_DC_TOWER_EMAIL_TEMPLATES_FILE |
./config/email-template.json |
Path (inside the container) to the JSON file with email subject/body templates. Must be mounted. |
Frontend¶
| Variable | Default | Description |
|---|---|---|
ALETYX_OIDC_PROVIDER |
entra |
entra or keycloak |
ALETYX_DC_TOWER_USE_MOCKS |
true |
Set to false for any deployment that should hit the real backend; the default is true to support the in-image quick-start without a database. |
ALETYX_DC_TOWER_APP_NAME |
Decision Control Tower |
Application title displayed in the UI |
ALETYX_DC_TOWER_CORS_ALLOWED_ORIGINS |
http://localhost:*,http://127.0.0.1:* |
Comma-separated list of origins allowed for CORS pre-flight |
ALETYX_DC_TOWER_TASKS_REFETCH_INTERVAL_MS |
10000 |
Polling interval (milliseconds) used by the UI to refetch the task list/count. Set to 0 to disable background polling. |
Health probe loop (Tower → Decision Control instances)¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_HEALTH_INTERVAL_MS |
30000 |
Probe interval in milliseconds |
ALETYX_DC_TOWER_HEALTH_TIMEOUT |
5 |
Probe timeout in seconds |
ALETYX_DC_TOWER_HEALTH_POOL_SIZE |
4 |
Thread pool used for outbound probes |
Task list¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_TASKS_PAGE_SIZE |
200 |
Default page size for task list endpoints |
ALETYX_DC_TOWER_TASKS_FUTURE_TIMEOUT |
8 |
Future-completion timeout in seconds |
ALETYX_DC_TOWER_TASKS_EXECUTOR_POOL_SIZE |
0 |
0 selects the framework default executor; override only with measured workload data |
Logging¶
| Variable | Default | Description |
|---|---|---|
ALETYX_DC_TOWER_LOG_LEVEL_ROOT |
INFO |
Root logger level |
ALETYX_DC_TOWER_LOG_LEVEL_APP |
INFO |
ai.aletyx.towercore.* package level |
ALETYX_DC_TOWER_LOG_LEVEL_WEB |
ERROR |
Spring Web / DispatcherServlet level |
ALETYX_DC_TOWER_LOG_LEVEL_HTTP |
ERROR |
HTTP client level |
ALETYX_DC_TOWER_LOG_LEVEL_SQL |
ERROR |
Hibernate SQL level (set to DEBUG only briefly for diagnosis) |
Secrets and credential management¶
The image accepts secret values via plain environment variables. Never embed secrets in the container image, in command-line arguments visible to other processes, or in version-controlled configuration. Use the secret-management primitive of your platform:
- Container / Podman: pass secrets via
--env-file <file>where the file is restricted (chmod 600) and stored under a directory not backed up to version control. For longer-running deployments use Container secrets or Podman's--secretflag. - Kubernetes: use a
Secretand reference it viaenvFromor per-keyvalueFrom.secretKeyRef. Secret encryption at rest must be enabled on the cluster (--encryption-provider-config). If your platform integrates an external secret store (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault), surface the values as environment variables — Tower itself only consumes environment variables. - OpenShift: use a
SecretwithrestrictedVolumeor the OpenShift Vault integration. Inherit the tenant's encryption-at-rest policy.
Secrets that must be provided in production:
| Secret | Source | Rotation |
|---|---|---|
ALETYX_DC_TOWER_DB_PASSWORD |
Database team / secret manager | Rotate at most every 90 days |
ALETYX_OIDC_ENTRA_CLIENT_SECRET |
Entra app registration | Rotate before the secret's expiry; default 24 months |
ALETYX_OIDC_CALLBACK_SECRET |
Operator-generated random string ≥32 chars | Rotate alongside sidecar; both sides must update simultaneously |
Worked examples¶
Production-shaped: PostgreSQL + Entra + sidecar¶
docker run --rm -d --name decision-control-tower \
-p 8080:8080 \
--env-file /etc/tower/tower.env \
--restart unless-stopped \
<your-registry>/aletyx/decision-control-tower:latest
Where /etc/tower/tower.env (mode 0600, owned by the deployment user) contains:
# Server
APP_HOST=https://dc-tower.example.com
# Database
ALETYX_DC_TOWER_DB_URL=jdbc:postgresql://db.internal:5432/decision_tower_db
ALETYX_DC_TOWER_DB_USERNAME=tower
ALETYX_DC_TOWER_DB_PASSWORD=<from-secret-manager>
# OIDC
ALETYX_DC_TOWER_USE_MOCKS=false
ALETYX_OIDC_PROVIDER=entra
ALETYX_OIDC_CLIENT_ID=<client-id>
ALETYX_OIDC_ENTRA_TENANT_ID=<tenant-id>
ALETYX_OIDC_ENTRA_AUDIENCE=<client-id>
ALETYX_OIDC_ENTRA_CLIENT_SECRET=<from-secret-manager>
# Sidecar
ALETYX_DC_TOWER_SIDECAR_URL=http://sidecar.internal:8080
ALETYX_DC_TOWER_DATA_INDEX_URL=http://sidecar.internal:8080
ALETYX_OIDC_CALLBACK_SECRET=<shared-secret>
# CORS
ALETYX_DC_TOWER_CORS_ALLOWED_ORIGINS=https://dc-tower.example.com
Custom port¶
If 8080 conflicts with another service on the host, remap consistently:
docker run --rm -p 9090:9090 \
-e APP_HOST=http://localhost:9090 \
-e APP_PORT=9090 \
-e BACKEND_PORT=9090 \
--env-file /etc/tower/tower.env \
<your-registry>/aletyx/decision-control-tower:latest
Sidecar (required companion container)¶
The Sidecar is a separate container image (decision-control-tower-sidecar) that Tower calls for process state, data index queries, jobs, and the SVG audit-trail rendering. A Tower deployment without a matching Sidecar will start and accept logins, but workflow-driven endpoints will silently no-op — deploy both together.
The Sidecar needs its own PostgreSQL database (distinct from Tower's), the URL of the Tower instance it should call back into, and the shared callback secret that matches Tower's ALETYX_OIDC_CALLBACK_SECRET:
docker run --rm -d --name decision-control-tower-sidecar \
-p 8181:8080 \
--env-file /etc/tower/sidecar.env \
--restart unless-stopped \
<your-registry>/aletyx/decision-control-tower-sidecar:<tag>
Where /etc/tower/sidecar.env (mode 0600) contains:
# Server
APP_HOST=0.0.0.0
BACKEND_PORT=8080
SPRING_PROFILES_ACTIVE=prod
# Database — canonical names read by the Sidecar's application.properties
# (spring.datasource.url=${ALETYX_DC_TOWER_SIDECAR_DB_URL}, etc.).
ALETYX_DC_TOWER_SIDECAR_DB_URL=jdbc:postgresql://db.internal:5432/decision_tower_sidecar_db
ALETYX_DC_TOWER_SIDECAR_DB_USERNAME=sidecar
ALETYX_DC_TOWER_SIDECAR_DB_PASSWORD=<from-secret-manager>
# How the Sidecar reaches Tower for callbacks.
ALETYX_DC_TOWER_SIDECAR_TOWER_CALLBACK_URL=http://decision-control-tower.internal:8080
# Shared callback secret — must equal Tower's ALETYX_OIDC_CALLBACK_SECRET.
ALETYX_OIDC_CALLBACK_SECRET=<shared-secret>
In the Tower tower.env shown earlier, point ALETYX_DC_TOWER_SIDECAR_URL and ALETYX_DC_TOWER_DATA_INDEX_URL at this Sidecar — http://sidecar.internal:8181 for the host-port mapping above, or the in-cluster Service hostname on Kubernetes (see chapter 4 under "Sidecar component (required)").
Health and verification¶
The container exposes a single unauthenticated health endpoint. Both Kubernetes liveness and readiness probes target it:
A healthy response is HTTP 200 with body:
If the backend cannot reach the database, the overall status becomes DOWN and the endpoint returns a non-200 status. Diagnose by checking the logs for a Hibernate / Hikari connection error.
The OIDC bootstrap endpoint:
returns the frontend's expected OIDC client configuration. Useful for verifying that the OIDC environment variables were read correctly without needing to exercise a full login flow.
Operational considerations¶
- The image runs as a non-root user. Volume mounts must be readable by the user defined in the base image.
imagePullPolicy(Kubernetes / OpenShift): useIfNotPresentfor tagged immutable releases (e.g. a specific version such as1.0.0) andAlwaysonly for floating tags (:latest,:main,:stage,:dev).- Image pinning by digest is recommended for production: Extract the digest of a running pod with:
- Graceful shutdown: Tower honors
SIGTERMand finishes in-flight requests before exiting. Default Tomcat shutdown timeout is 30 seconds; allow at least that on container stop policies. - Cold-start time: 6–10 seconds on a 2-core / 2 GiB allocation. The first authenticated request after startup may take longer because the JVM lazily warms up the OIDC HTTP client and the database connection pool. This is expected; subsequent requests return promptly.