Environments
Audience: Administrators, platform operators, infrastructure engineers
Time: ~15 min to read; ~5 min to add or rotate an environment
Prerequisites: Control Plane is up at https://<your-host>/, you can reach Settings → Environments, and you have admin role.
TL;DR
- Control Core ships with two environments by default:
sandbox(lowest, where controls are authored) andproduction(where promoted controls run live). - A single Control Plane is the master for all environments; never stand up a second Control Plane.
- You may add up to 3 additional environments (5 total) — typically
dev,staging,qa, etc. Each environment owns its own Bouncers, Resources, GitHub config, PIP data sources, API key, and (optionally) SPIRE workload identity. - Each environment has one unified API Key: it is the secret a Bouncer uses to register with the Control Plane and the secret API/SDK integrations (Policies-as-Code, OEM SDK) use for that environment.
- The API Key is generated automatically at install — it never blocks deployment. You can rotate it from
Settings → Environmentsany time. - Controls can be created only in the lowest environment (
sandbox). They are then promoted upward and modified in higher environments — never created there.
What "environment" means in Control Core
An environment is an isolation boundary inside one Control Plane:
| Scoped per environment | Shared across environments |
|---|---|
| Bouncers (PEPs) and the Resources they protect | The Control Plane itself (UI + API + DB + embedded policy distribution server) |
| Policy/Control state (active in this env, version, status) | Control templates catalog |
| GitHub repository / branch / policy folder | User identities, SSO, RBAC |
| PIP (Policy Information Point) data source connections | Tenant settings (timezone, session timeout, billing) |
| Notification routes and Action Destinations | Telemetry / license metering |
| Approval gates configuration | Background jobs / scheduler |
| Environment API Key (unified) | Single SPIRE trust domain (per-env namespace under it) |
| Optional SPIRE workload identity | |
| Audit/decision logs (queryable per env) |
Topology:
┌──────────────────────────┐
│ Control Plane │
│ (UI + API services) │ ← one per customer
│ master for all envs │
└──────────┬───────────────┘
│
┌───────────────────┼───────────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Sandbox │ │ Prod │ │ Extra │ (up to 3 more)
│ env key │ │ env key │ │ env key │
│ bouncers│ │ bouncers │ │ bouncer │
│ resources │ resources│ │ resource│
└─────────┘ └──────────┘ └─────────┘
Why one Control Plane? It is the single management surface for policy authoring, audit visibility, and license metering. Splitting it would fragment audit history, break the promotion flow, and double the licensing cost. Bouncers are the part that scales horizontally per environment.
The Environment API Key
A single key per environment serves both:
- Bouncer ↔ Control Plane registration. When you start a Bouncer, you give it the API Key for the environment it belongs to. The Bouncer presents the key on its first call to
POST /peps/registerand on every subsequent heartbeat. - API/SDK integrations. Policies-as-Code, the OEM SDK, and any external automation that calls the Control Plane API for that environment authenticate with the same key.
Key prefixes make accidental cross-environment use obvious:
| Environment slug | Prefix |
|---|---|
sandbox | cc_sandbox_… |
production | cc_prod_… |
staging (or any custom slug) | cc_<slug>_… |
When is it generated?
- Auto-generated at install. The deployment scripts and Helm chart write a fresh, high-entropy key for each default environment into both the Control Plane database and the Bouncer's environment variable / Kubernetes Secret. Installation never blocks on you logging in to mint a key.
- You can re-generate it any time from
Settings → Environments → <env> → Rotate API Key. The old key remains valid for a 60-minute grace window so Bouncers can re-roll without dropping traffic.
Is it a blocker for installation?
No. If the install process generates one for you, deployment proceeds end-to-end without the page being touched. You only return to the page if you want to:
- View whether a key is configured for an environment (status pill).
- Rotate the key (e.g. after a personnel change or as part of a scheduled rotation).
- Generate the first key for a newly added environment.
Lifecycle and rotation
install ─► key auto-generated, written to PAP DB + Bouncer secret store
operate ─► Bouncers register and heartbeat with the key; SDK integrations use it
rotate ─► admin clicks "Rotate API Key" → new plaintext shown ONCE
old key remains valid for 60 min (grace window)
ops rolls Bouncers (compose restart / kubectl rollout) onto new key
old key revoked at end of grace window
Always copy the plaintext key the moment it is shown — the Control Plane only stores the hash.
Cheatsheet — Helm and Compose values
Hand this to the infrastructure team when they update deployment manifests.
| Setting | Helm (values.yaml) | Docker Compose (.env or compose env block) | Where it goes |
|---|---|---|---|
| Environment slug | controlcore.environments[i].slug | ENVIRONMENT=<slug> (per Bouncer) | Bouncer + PAP |
| Display name | controlcore.environments[i].displayName | n/a | PAP only |
| API Key (auto if absent) | controlcore.environments[i].apiKey.secretKeyRef | BOUNCER_API_KEY=<value> (per Bouncer) | Bouncer |
| Mark as lowest (only one allowed) | controlcore.environments[i].isLowest: true | n/a | PAP only |
| GitHub repo | controlcore.environments[i].github.repo | GITHUB_REPO_<SLUG>=… | PAP |
| GitHub branch | controlcore.environments[i].github.branch | GITHUB_BRANCH_<SLUG>=… | PAP |
| GitHub policy path | controlcore.environments[i].github.policyPath | GITHUB_POLICY_PATH_<SLUG>=… | PAP |
| SPIRE enabled | controlcore.environments[i].spire.enabled | SPIRE_ENABLED_<SLUG>=true | PAP + Bouncer |
| SPIRE trust domain | controlcore.environments[i].spire.trustDomain | SPIRE_TRUST_DOMAIN=… | PAP + Bouncer |
| SPIRE Workload API socket (Bouncer) | bouncer.spire.workloadApiSocket | SPIRE_WORKLOAD_API_SOCKET=/run/spire/sockets/agent.sock | Bouncer |
| Bouncer ↔ Control Plane URL | bouncer.controlPlaneUrl | PAP_API_URL=https://<host>/api | Bouncer |
Tip: When you add a 3rd / 4th / 5th environment from the UI, the Control Plane shows you a snippet pre-filled with the new slug and API Key. Drop that snippet into your Helm values or compose file and roll the Bouncer.
Adding more environments later
Settings → Environments → Add Environment(button is disabled at 5).- Pick a slug (lowercase, hyphenated, unique — e.g.
staging). - Optionally set GitHub repo/branch/path for that env (otherwise inherits from the lowest env's repo on first publish).
- Click Create. The page generates the API key once and shows it.
- Copy the deployment cheatsheet snippet shown by the modal.
- In your infrastructure tool of choice (Helm or compose), add the new environment block and the new Bouncer that points at it. Roll out.
- The new Bouncer registers itself with the Control Plane on first start; you'll see it in
Settings → Bouncersand its protected resource inSettings → Resources.
SPIRE workload identity (optional, per environment) [#spire]
SPIRE provides cryptographic workload identity (SPIFFE SVIDs) so that a Bouncer authenticates to the Control Plane (and, where supported, the Control Plane to Redis, Postgres, and the policy sync path) using mTLS with short-lived rotated certificates instead of a long-lived shared secret.
Posture matrix
| Posture | API Key required? | SVID required? | Use when |
|---|---|---|---|
| Default (SPIRE disabled) | Yes (env API key) | No | Single Control Plane + small Bouncer fleet, simplest install |
SPIRE-only (env spire_enabled=true) | No (ignored if presented) | Yes | Production fleets, customer compliance demands no shared secrets |
SPIRE-required globally (SPIRE_ATTESTATION_REQUIRED=true) | No | Yes (every env) | Fleet-wide hardening |
Per the Control Plane bouncer registration policy:
- When the env row has
spire_enabled=true, the Control Plane requires a valid SVID matchingspiffe://<trust-domain>/bouncer/<bouncer-id>on every register/heartbeat. The API Key is not consulted. - When SPIRE is disabled for that env, the API Key path is the only path. SVID headers are ignored.
- The global
SPIRE_ATTESTATION_REQUIRED=trueenv-var override forces SPIRE on every environment regardless of UI state.
Wiring (Bouncer side)
The Bouncer ships with the SPIRE Workload API agent socket already wired. Set:
SPIRE_AGENT_SOCKET=/run/spire/sockets/agent.sock
SPIRE_TRUST_DOMAIN=<value matching the env row> # only needed for legacy global config
In Kubernetes, mount the SPIRE agent socket as a hostPath volume on the Bouncer pod. In Docker Compose, mount the host socket via:
volumes:
- /run/spire/sockets:/run/spire/sockets:ro
Envoy already has SDS configured to fetch SVIDs over this socket; no additional cert plumbing is required on the Bouncer side.
Wiring (Control Plane internal connections)
The Control Plane is a single-instance service so its internal connections (Postgres, Redis, and policy distribution) cannot be per-env in the strict sense. Instead, the internal SPIRE helper resolves the Control Plane posture as:
- SPIRE is on for internal traffic if any active env has
spire_enabled=trueorCC_SPIRE_INTERNAL=trueis set. - Trust domain comes from the lowest env row first, falling back to
SPIRE_TRUST_DOMAIN.
When that returns enabled=true, your infra team should:
- Ensure the Control Plane Pod has the SPIRE agent socket mounted.
- Switch Postgres, Redis, and policy-distribution connection strings from cleartext to mTLS variants.
- Issue per-workload SPIFFE IDs of the form
spiffe://<trust-domain>/control-plane/<service>(e.g.…/control-plane/api).
Cross-reference
For the full SPIRE installation walkthrough (registering workloads, rotating trust bundles, federation): Workload Identity (SPIRE/SPIFFE).
Runbook — Adding an environment [#add-env]
Use this when promoting a 2-env install to 3, 4, or 5 environments. Total cap: 5 active envs.
Goal
Add a new environment slug staging between sandbox and production with its own Bouncer fleet, GitHub config, and API Key.
Steps
- Plan the slug. Lowercase, hyphenated, 3–32 chars, unique. The slug is immutable (used in deployment scripts, policy bundle paths, audit records).
- From the UI:
Settings → Environments → Add Environmentbutton.- Slug:
staging - Display name:
Staging - Description:
Pre-prod soak before promotion to production(optional) - Click Create & Mint Key. The API key plaintext is shown once. Copy it now.
- Slug:
- From the CLI (alternative). If you bootstrap from a script, run inside the Control Plane API container:
docker exec <control-plane-api-container> python -m app.scripts.seed_environment \ --slug staging \ --display-name Staging \ --emit-key-to /tmp/staging.api-key docker cp <control-plane-api-container>:/tmp/staging.api-key ./staging.api-key - Update Helm values (or compose
.envfor non-K8s installs):controlcore: environments: - slug: sandbox isLowest: true ... - slug: staging displayName: "Staging" description: "Pre-prod soak" apiKey: secretRef: cc-env-staging-api-key - slug: production ... - Apply & roll the Bouncer. Helm:
helm upgrade controlcore ./helm-chart/controlcore -f values.yaml kubectl rollout status deployment/cc-bouncer-staging
Compose / VM installs:
# Roll the Control Plane and Bouncers using your deployment runbook after updating environment configuration.
- Verify.
Settings → Bouncersshows the new Bouncer registered.Settings → Resourcesshows its protected resource.Settings → GitHublets you optionally set a per-env repo/branch/path.Audit Logsfiltered tostagingshows theENVIRONMENT_CREATEDrow.
Troubleshooting:
409 Cap exceededYou're at the 5-env cap. Either deactivate an unused environment (Settings → Environments → <env> → Delete) or consolidate two custom envs.
Promoting controls into the new environment
Promotion follows the Environment.order ladder. By default, Sandbox (100) → Staging (150) → Production (200). To promote a control:
curl -X POST "https://<host>/api/v1/policies/<policy-id>/promote" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"target_environment": "staging"}'
If target_environment is omitted, the next environment in the ladder is used.
Runbook — Rotating an Environment API Key [#rotate-key]
This is a zero-downtime rotation. The old key is valid during a 60-minute grace window so Bouncers can re-roll without dropping traffic.
Steps
- Pre-flight. Identify all consumers of the env's API Key:
- All Bouncers tagged with
ENVIRONMENT=<slug>(find them withSettings → Bouncersfiltered by env). - Any external automation calling the Control Plane API for that env (Policies-as-Code CI jobs, OEM SDK clients, ChatOps integrations).
- All Bouncers tagged with
- Rotate from the UI.
Settings → Environments → <env> → Rotate API Key. The new plaintext is shown once in a yellow banner. Copy it immediately into your secret store. - Push the new key to your secret store. AWS Secrets Manager / Azure Key Vault / Hashicorp Vault / Kubernetes Secret. Tag with
controlcore.io/environment=<slug>for traceability. - Roll the Bouncers within 60 minutes.
- Kubernetes:
kubectl rollout restart deployment/cc-bouncer-<slug>(the new Pod picks up the rotatedcc-env-<slug>-api-keySecret on start). - Compose:
docker compose up -d cc-bouncer-<slug>after updating the.envfile.
- Kubernetes:
- Update external automation. Update CI variables, ChatOps secrets, OEM SDK config — anywhere the previous plaintext was used.
- Verify.
Settings → Environments → <env> → Active Keysshows the new key with statusactiveand the previous key with statusgrace. After 60 minutes the previous key flips torevoked.Audit Logsfiltered to the env shows two rows:API_KEY_ROTATEDand (after 60 min)API_KEY_REVOKED.
Troubleshooting: a Bouncer fell off-fleet during rotation Symptom: a Bouncer logs 401 after the grace window, the heartbeat stops, the Bouncer goes red on
Settings → Bouncers. Fix: copy the current key from the secret store (the rotated one), restart the Bouncer (kubectl rollout restart …ordocker compose restart cc-bouncer-<slug>), confirm a heartbeat lands within 60s. Prevention: add a watchdog Job to your CI/CD that runscurl -fsS -H "Authorization: Bearer <new-key>" https://<host>/api/v1/peps/<bouncer>/heartbeatagainst every Bouncer immediately after a rotation.
Troubleshooting: revocation never happens Cause: the key-rotation watchdog job in the Control Plane API is paused or the container restarted during the grace window. Fix:
Settings → Environments → <env> → Active Keys → <old-key> → Revoke. The old key is invalidated immediately; anAPI_KEY_REVOKEDaudit row is written.
Troubleshooting
Troubleshooting:
404 Not Foundon/settings/environmentsOlder Control Plane builds mounted the settings router only at/settings/...(no/v1prefix). The UI calls/api/v1/settings/environments. Upgrade the Control Plane to a build that includes the/v1alias, or check that your reverse proxy doesn't strip the path differently than the rest of/api/*. Verify:curl -fsS https://<host>/api/v1/settings/environments -H "Authorization: Bearer <token>"should return 200.
Troubleshooting: Bouncer cannot register Symptom: Bouncer container logs
401 UnauthorizedfromPOST /peps/register. Causes: (1) WrongBOUNCER_API_KEYfor this environment; (2) using acc_sandbox_key but registering underproduction; (3) old key already past the rotation grace window. Fix: confirm the env slug and key match. From the Control Plane:Settings → Environments → <env>— the configured-status pill must show green.
Troubleshooting: Rotation broke a fleet of Bouncers Cause: Bouncers were not rolled within the 60-minute grace window. Fix: re-generate the key, re-distribute it to your secret store (Kubernetes Secret, AWS Secrets Manager, Vault), and roll the Bouncers immediately. Use
kubectl rollout restart deployment/<bouncer>ordocker compose up -d <bouncer>.
Troubleshooting: New environment is not visible to policy sync Cause: subscription directories are scoped per environment. After adding a new environment, the embedded policy distribution server must pick up the new scope record. Fix: a
Sync NowfromSettings → GitHubfor that environment, or wait for the next polling tick (default 30 s).
Troubleshooting: I see two Bouncers for the same environment after a rename The Control Plane deduplicates per
(environment, name). If you renamed a Bouncer mid-flight, the old row is automatically swept by the orphan-PEP watchdog (every 5 min). If you need to force the cleanup: restart the Control Plane API container to trigger the cold-boot heal pass.