Deploying continuo
continuo ships as a single Helm chart, deploy/continuo, that
installs every backend service plus optional bundled quickstart datastores
(PostgreSQL, Redis, Neo4j, MinIO, Dex). This page is the entry point; the
chart’s own README is the authoritative install
reference.
Install paths
| I want to… | Go to |
|---|---|
| Try continuo on any cluster with zero external accounts | Quickstart — one helm install, bundled datastores, demo login |
| Install from the published chart without cloning this repo | OCI install — helm install continuo oci://ghcr.io/carolsimone/charts/continuo --version <X.Y.Z> |
| Run it in production with my own Postgres/Redis/Neo4j/S3/OIDC | Production (BYO datastores) + values-byo.yaml.example |
| Understand the security posture before adopting | SECURITY.md and the chart’s Security defaults |
| Configure real OIDC authentication | AUTH.md |
| Release a service from my CD pipeline | Releasing from CI — the public /api/v1 API with a GitHub Actions token |
| Release by hand, or try the API on a local install | Releasing with your own token — mint an ID token and call /api/v1 with it |
| Control which hosts the assistant and the fix proposer can reach | Outbound traffic — the egress proxy and its allowlist |
| Back up the catalog graph, or restore it after Neo4j is lost | Catalog backup and restore — the nightly export to the bucket, catalog-backup, and the S3 actions the export needs |
| Build a dbt image for my team’s models | dbt-image-contract.md |
| Understand how releases are cut and verified | Release flow and CI gates |
What is NOT in this repo
The infrastructure that backs the reference production deployment (a
Bitnami-based Postgres/Redis/Neo4j stack with Hetzner-specific storage
classes) lives in a separate private repository, continuo-infra, deployed
manually from its own runbook. From this chart’s point of view that stack is
simply “datastores you bring” via the external* values — any equivalently
reachable Postgres 15+, Redis 7+, and Neo4j 5.x work the same way.
Licensing of the bundled datastores
continuo itself is Apache-2.0. The chart’s optional quickstart mode
(postgresql.enabled, redis.enabled, neo4j.enabled, minio.enabled) pulls
upstream container images that carry their own licenses:
| Component | Image | License |
|---|---|---|
| PostgreSQL | postgres:18.3 |
PostgreSQL License (permissive) |
| Redis | redis:8.6.4 |
AGPLv3 / RSALv2 / SSPLv1 (tri-licensed) |
| Neo4j | neo4j:5.26.28-community |
GPLv3 |
| MinIO | ghcr.io/carolsimone/continuo-minio (Bitnami packaging of MinIO, mirrored) |
AGPLv3 (MinIO) + Apache-2.0 (Bitnami packaging) |
| Dex | dexidp/dex:v2.41.1 |
Apache-2.0 |
These images are pulled and run as separate processes. continuo does not link against, embed, modify, or redistribute their code, so their licenses do not extend to continuo or to your use of it.
If your organisation’s policy prohibits running copyleft-licensed datastores,
use the external datastore mode (external* / existingSecret values) and
bring your own — that is the supported production configuration anyway.
For the full dependency inventory across Go, npm, and Python, see docs/third-party-licenses.md.
API credentials for python-api nodes
A python-api node can name a Secret in its contract (secret_ref) to receive
an API key. The Secret is called continuo-api-<name>, lives in the namespace
execution-controller runs Jobs in (its K8S_NAMESPACE, which the chart sets to
the release namespace), and is created by the operator: kubectl create secret, an External Secrets Operator ExternalSecret, or a Vault Secrets
Operator VaultStaticSecret. The chart neither creates nor reads these Secrets.
The continuo-api- prefix is reserved for them: the chart refuses to render
when the release name, fullnameOverride or any Secret name in values falls
inside it.
See python-api nodes.
Releasing from CI (GitHub Actions)
A CD pipeline releases a service with one authenticated HTTPS call to the
ui, at https://<continuo-host>/api/v1/releases. It needs no kubeconfig, no
SSH access and no stored secret: the workflow proves which repository it runs
in with its GitHub Actions OIDC token, and the chart’s ciAuth values say which
repository may release which service.
1. Find the repository id
continuo trusts the numeric repository id, never the repository name (a name can be taken over by a new owner after a rename; the id cannot).
gh api repos/OWNER/REPO --jq .id
2. Bind the repository to a service
ciAuth:
bindings:
core: # the service name the release carries
- repositoryId: "812345678" # quoted: a bare number fails the chart's schema
ref: [refs/heads/main]
allowBootstrap: true # first release only; set false afterwards
A binding grants one GitHub identity the right to release one service. Every
field you set must match the token; repositoryId is the only required one.
| Field | Matched against | Meaning |
|---|---|---|
repositoryId |
repository_id |
Exact digits (required). |
ref |
ref |
The token’s ref is one of the listed values. |
refProtected |
ref_protected |
true allows only protected refs. |
environment |
environment |
The job’s environment is one of the listed values. |
workflowRef |
workflow_ref |
The workflow file and ref is one of the listed values. |
allowBootstrap |
none | Permits "bootstrap": true (default false). |
A service may list several bindings; a token that matches any one of them may
release it. With no bindings (the default) no pipeline can use the API: ui
does not trust the GitHub issuer at all, so every CI token is a 401. A token
whose repository matches no binding is refused on every route, including
GET /api/v1/current-prod. The other ciAuth keys are issuer (default
https://token.actions.githubusercontent.com; GitHub Enterprise Server uses
https://<host>/_services/token) and audience, which defaults to the origin of
auth.publicUrl (scheme, host and port, no path). Changing ciAuth rolls the
ui on helm upgrade.
3. Call the API from the workflow
The workflow requests a token whose audience is the same origin continuo expects,
then submits the release and polls it to a terminal status. This example
releases a dbt service, so it sends only the three required fields. kind
names how continuo reads the service’s artifact: dbt (the default when absent)
or python. The first release of a service has no production version to
validate against, so it is sent with "bootstrap": true, which needs a binding
with allowBootstrap: true. These and the other submit fields are described
below.
permissions:
id-token: write
contents: read
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
# The origin of auth.publicUrl: scheme://host[:port], no path, no trailing slash.
CONTINUO_URL: https://continuo.example.com
steps:
- name: Submit release to continuo
run: |
token() {
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$CONTINUO_URL" | jq -r .value
}
RELEASE_ID="rel-${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}"
curl --fail-with-body -sS -X POST "$CONTINUO_URL/api/v1/releases" \
-H "Authorization: Bearer $(token)" -H 'content-type: application/json' \
-d "$(jq -n --arg id "$RELEASE_ID" --arg tag "${GITHUB_SHA::7}" \
'{release_id:$id, service:"core", image_tag:$tag}')"
for _ in $(seq 1 300); do # 300 polls x 10 s = 50 minutes
s=$(curl -sS -H "Authorization: Bearer $(token)" \
"$CONTINUO_URL/api/v1/releases/$RELEASE_ID") || s='{}'
[ "$(jq -r .terminal <<<"$s")" = true ] && break
sleep 10
done
jq . <<<"$s"; [ "$(jq -r .status <<<"$s")" = promoted ]
GitHub Actions tokens expire within minutes, so the example asks for a fresh one on every call instead of reusing the first.
The API
All three routes live under /api/v1 on the ui. Each takes
Authorization: Bearer <token>; the scheme name is case-insensitive.
| Route | Purpose |
|---|---|
POST /api/v1/releases |
Submit a release. Answers 202 with {release_id, status}. |
GET /api/v1/releases/{id} |
Read a release (below). |
GET /api/v1/current-prod |
Which release production is serving: {current_prod_release_id, node_count, updated_at}. An empty current_prod_release_id means production has never been seeded. |
Submit body. release_id, service and image_tag are required;
bootstrap (a JSON boolean), kind (dbt, the default, or python), repo
and commit_sha are optional. Any other field is a 400. release_id must
match ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$: it starts with a letter or digit and
continues with letters, digits, ., _ or -, at most 128 characters in all;
anything else is a 400. With a CI token, repo and commit_sha are taken from
the token (repository and sha): leave them out, or send values equal to the
token’s, because a different value is a 403 claim_mismatch. A person calling
with their own token (see AUTH.md) must send repo and
commit_sha in the body. "bootstrap": true promotes without validation and is
for the first release of a service; a CI token may send it only when its
binding sets allowBootstrap. Submitting the same release_id again with the
same body is idempotent and answers 202; the same release_id with a
different service, image_tag, kind, bootstrap flag, repo or commit_sha
is a 409 release_kind_conflict, so use a fresh id for a new release.
Release read. The answer carries release_id, service, status,
terminal (true once the status is promoted, rejected or superseded),
bootstrap, repo, commit_sha, reject_reason, reject_detail, and ui_url,
the release’s page in the dashboard. A CI token can read only releases of the
services its bindings grant; a release of any other service answers 404
not_found, exactly as a release that does not exist. A pipeline polling for
terminal stays well inside the read limit (300 a minute per principal) with
one read every few seconds.
Errors. Every failure is JSON, {"error": "<message>", "code": "<code>"}.
| Status | code |
Meaning |
|---|---|---|
| 400 | bad_request |
Malformed JSON, an unknown or mistyped field, a missing required field, or a release_id outside the pattern above. |
| 401 | invalid_token |
The bearer token is malformed, expired, for the wrong audience, from an untrusted issuer, signed with an algorithm other than RS256, or valid for longer than one hour. The response carries WWW-Authenticate: Bearer error="invalid_token". A request with no credential at all answers 401 with code unauthenticated. |
| 403 | forbidden |
The identity may not do this: a repository not bound to the service it submits, a repository bound to no service at all (on every route), a viewer submitting, or a CI token calling any other route. |
| 403 | claim_mismatch |
The body’s repo or commit_sha differs from the token’s. |
| 403 | bootstrap_not_allowed |
"bootstrap": true from a binding without allowBootstrap. |
| 404 | not_found |
No such release, a release of a service the CI token’s repository is not bound to, or an id outside the release_id pattern. |
| 409 | release_kind_conflict |
The release_id already names a release with a different service, image_tag, kind, bootstrap flag, repo or commit_sha. The release service makes this check. A person’s error carries its message; a CI token gets release id already exists with different content, which names no other release. |
| 429 | rate_limited |
More than 30 submits, or more than 300 reads (GET /api/v1/releases/{id} and GET /api/v1/current-prod together), in a minute from one principal (a repository for CI, a user for a person), counted per ui pod. Submits and reads have separate budgets. |
| 503 | auth_unavailable |
The token’s issuer could not be reached, its discovery document or signing keys could not be read, or its key set has no usable RS256 key, publishes a kid twice, or holds an RSA key shorter than 2048 bits; the dashboard keeps working. Retry. |
| 503 | upstream_unavailable |
The release service is unreachable or failed. Retry; a submit is idempotent. |
Networking
- The ingress host (
ingress.host) must be reachable from GitHub-hosted runners. A cluster behind a firewall needs self-hosted runners that can reach it. - The
uipod fetches the issuer’s signing keys over HTTPS, so it must reachtoken.actions.githubusercontent.com(or your GitHub Enterprise Server host) on port 443. The chart’s NetworkPolicies restrict theui’s ingress only, so this works by default; a cluster-wide egress policy or a forced proxy must allow it. While the issuer is unreachable, bearer calls answer503auth_unavailableand the browser login is unaffected. - Where egress must go through a proxy, add
HTTPS_PROXYtogether withNODE_USE_ENV_PROXY=1to theuientry’senvinservices, plusNO_PROXYcovering the in-cluster service names theuicalls (release-controller,run-controller,catalog,agent-chat,agent-remediation, and your IdP when it is in-cluster). Theuiruns on Node.js 26, whose built-infetchhonours the proxy variables only whenNODE_USE_ENV_PROXY=1is set; without it the key fetch ignoresHTTPS_PROXYand tries the issuer directly.
Releasing with your own token
A person calls the same /api/v1 routes with their own bearer token: an ID
token from the ui’s login provider, for an identity with the operator role
(see AUTH.md). It is valid for at most one hour; mint a
new one when a call answers 401.
On a local install with the bundled Dex, forward the ui (the API) and Dex
(the token issuer), then trade the demo operator account’s password for a
token:
kubectl -n continuo port-forward svc/ui 8090:8090 &
kubectl -n continuo port-forward svc/continuo-dex 5556:5556 &
CLIENT_SECRET=$(kubectl -n continuo get secret continuo-dex -o jsonpath='{.data.client-secret}' | base64 -d)
TOKEN=$(curl -s -u "continuo-ui:${CLIENT_SECRET}" http://localhost:5556/dex/token \
-d grant_type=password -d scope="openid email profile" \
-d username=admin@example.com -d password=password | jq -r .id_token)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8090/api/v1/current-prod
If echo $TOKEN prints null, the login failed: check the Dex port-forward
and the password. A release posted with a person’s token must carry repo and
commit_sha in its body, since there is no CI token to supply them:
curl -s -X POST http://localhost:8090/api/v1/releases \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"release_id":"rel-core-v1","service":"core","image_tag":"v1","repo":"<owner>/<repo>","commit_sha":"<sha>"}'
With your own IdP, the token is an ID token it issues for the ui’s client id;
how a script obtains one depends on the IdP. An operator token may also
bootstrap a service ("bootstrap": true).
Service deployment controls
Each services entry requires name and enabled. Setting enabled: false
omits that entry’s Deployment, Service, ServiceAccount, Role and RoleBinding.
An enabled entry defaults to one replica when replicas is omitted; an explicit
replicas: 0 keeps its resources and scales its Deployment to zero.
httpPort and grpcPort are independent optional Service ports. Either one
creates a Service exposing only the configured ports. An entry with neither
port still creates its Deployment and requested RBAC, without a Service.
Readiness and liveness probes follow the ports: an entry with httpPort is
probed over HTTP on it (readinessPath, livenessPath, probePath), a
gRPC-only entry by a TCP connect to its grpcPort, which works in the Go
service images, and an entry with neither port by a Python process check, so
its image must include Python.
Validating service controls
The standard bash scripts/install-test/lint.sh gate includes render regressions
for enablement, replica counts, ports and RBAC. The optional live test uses the
production service templates in an isolated Helm release, without starting the
full continuo dependency stack. Its workload is a Go TCP echo on alpine
without Python, as the shipped Go images are. It checks scale-to-zero, that a
gRPC-only entry becomes ready, TCP routing through its Service, and resource
removal on disable. It deletes its test namespace.
With Helm, kubectl and a disposable current cluster configured, build the fixture
and make the image available to the cluster (for kind, load it with kind load docker-image continuo-chart-echo:test --name <cluster>), then run:
docker build -t continuo-chart-echo:test scripts/install-test/fixtures/service-controls
python3 scripts/install-test/test-service-controls-live.py continuo-chart-echo:test
Outbound traffic
agent-chat and agent-remediation are the two services where a language model
decides what happens next, so they reach the outside world only through
egress-proxy (egress.enabled, on by default). The proxy admits a
connection only to a host and port on its allowlist, resolves the name itself,
refuses loopback, unspecified, link-local and cloud-metadata addresses always
and private addresses unless the entry allows them, and connects only to the
addresses it checked. The chart sets HTTP_PROXY, HTTPS_PROXY and NO_PROXY
on both agents, and, with networkPolicy.enabled, their NetworkPolicy drops
every other outbound connection.
The allowlist follows your settings. The chart builds it from:
| Setting | Allowed |
|---|---|
llm.baseUrl empty, llm.provider: anthropic |
api.anthropic.com:443 |
llm.baseUrl empty, llm.provider: openai |
api.openai.com:443 |
llm.baseUrl set |
its host and port, which may resolve to a private address |
GITHUB_BASE_URL in the env of agent-remediation’s services entry (default https://api.github.com) |
api.github.com:443 and codeload.github.com:443 |
a GitHub Enterprise GITHUB_BASE_URL |
its host and codeload.<host> on its port, which may resolve to private addresses |
s3.endpointUrl (with minio.enabled: false) |
its host and port, which may resolve to a private address |
egress.allowedDomains / egress.privateDomains |
the entries listed |
See what it holds with kubectl -n <namespace> get configmap <release>-egress-proxy -o yaml (for a release named continuo, the ConfigMap
is continuo-egress-proxy). The agents’ S3 client reaches the endpoint through
the proxy, and signs its requests with the static keys the chart gives it
(s3.accessKeyId and s3.secretKey, or s3.existingSecret; an external bucket
requires them), so it makes no call to STS or the instance metadata service.
A model you host. Point the chart at its root URL:
llm:
provider: openai-compatible
baseUrl: http://gpu-box.internal:8000 # the root, without /v1
model: llama-3.3-70b
A host name goes through the proxy and may resolve to a private address. An IP
address (http://10.0.0.7:8000) is reached directly: the chart leaves it out of
the allowlist, adds it to NO_PROXY and admits it, on its port, in the agents’
NetworkPolicy. Either way nothing else needs configuring, as long as the port is
one the model’s pod listens on:
A NetworkPolicy sees a connection after a Service has translated it, as the pod’s address and port. So a port you configure must be one the pod listens on, whether the address is reached directly or through the proxy: a Service that maps port 80 to a pod port 8000 is not reachable on port 80, because the proxy reaches private addresses only on the port of the allowlist entry, and the agents only on the port of the direct destination. An IP address is for a machine or a pod, not for a Service.
A host must be a DNS name of letters, digits and inner hyphens, or an IP
address. A llm.baseUrl, GITHUB_BASE_URL or s3.endpointUrl whose host is
anything else (http://vllm_x:8000, with an underscore, for one) fails the
render with a message that names the setting and the value; use a DNS-valid
name or the IP address. The same holds for a port outside 1-65535.
Something else the agents reach. Add a host name to egress.allowedDomains
(host or host:port, port 443 when omitted; *.example.com for every name
below it), or to egress.privateDomains when it resolves to a private address.
An address that is not reached through the proxy goes in
egress.directDestinations:
egress:
directDestinations:
- {cidr: 203.0.113.10/32, ports: [5432]}
Postgres and Redis speak their own protocols and ignore the proxy. With
networkPolicy.enabled, an external Postgres or Redis on a private network, or
running as a pod of any namespace, is already admitted on its configured port
(externalDatabase.port, externalRedis.port), and one whose
externalDatabase.host or externalRedis.host is a public IP address is added
for you. One whose host is a name that resolves to a public address needs an
entry: the chart cannot know where the name points, and helm upgrade prints
the configured host as a reminder. Each entry is checked as written, and the
render fails naming it unless:
cidris an IPv4 or IPv6 range with a prefix length of 1 or more and no address bit set past it.0.0.0.0/0and::/0are refused.- the range overlaps none of loopback, link-local, the unspecified network, a
cloud metadata address, IPv4-mapped IPv6 and a NAT64 address standing for one
of those. A whole
100.64.0.0/10and a wholefc00::/7are refused because they contain metadata addresses (100.100.100.200,fd00:ec2::/32): list the narrower ranges you need. portsis a non-empty list of whole numbers from 1 to 65535. Write them without a leading zero: YAML reads an unquoted0443as the octal number 291, which the chart cannot tell from291, so it opens port 291 and renders silently. A quoted"0443"is not a number and fails the render.
How the agents are kept to those rules. NO_PROXY lists, so that the
agents reach them directly:
localhost,127.0.0.1and::1;- the release’s own Services, in every form (
name,name.<namespace>,name.<namespace>.svc,name.<namespace>.svc.cluster.local) and on every port; - every other in-cluster address the agents are given (a
global.*GrpcAddr, or a URL orhost:portin theirenv) ashost:port, when its host has no dot or ends in.svcor.svc.cluster.local, unless the allowlist admits that host and port, in which case the proxy carries it; - every IP address the agents are given (the host of a URL, or of a URL or
host:portin theirenvor aglobal.*GrpcAddr, which egress-proxy would refuse because it admits host names only), an external Postgres or Redis at a public IP address, and eachegress.directDestinationsrange. A loopback address needs no entry, nor does a listen address (0.0.0.0) in theirenv; one in link-local or a cloud metadata range fails the render, naming the setting.
Every other address goes through the proxy. A <name>.<namespace> address in
another namespace written without .svc, and a name with a trailing dot, are not
treated as in-cluster: they go through the proxy, which refuses them unless the
allowlist names them. Write <name>.<namespace>.svc.
With networkPolicy.enabled, the agents may reach:
- DNS (port 53);
- every pod of the release namespace on any port: the proxy, the services and the bundled datastores;
- an external Postgres or Redis on its configured port, on the private ranges and on pods of any namespace;
- every pod of another namespace on the port of each in-cluster address the
agents are given there (
foo.tools.svc:80admits every pod oftoolson port 80, not only the target pods); - the
egress.directDestinationsranges and every IP address they are given, including an external Postgres or Redis at a public IP address, on their ports.
egress-proxy itself may resolve names, reach public addresses on its allowlisted ports, and reach private addresses and pods of any namespace only on the ports of entries that allow private addresses. Only the two agents may connect to it, on its proxy port.
When there is no proxy. An install whose every destination is an IP address
(a model, GitHub Enterprise and S3 at IP addresses, or the bundled MinIO) and
that lists no egress.allowedDomains or egress.privateDomains has an empty
allowlist, which the proxy refuses to start on. The chart then renders no
egress-proxy object and sets no proxy variable; the agents reach those addresses
directly, and their NetworkPolicy still limits them to the destinations above.
When a call is refused, egress-proxy logs a warning egress denied with the
host, port, client address, method and reason, and counts it in
continuo_egress_requests_total{decision="denied"}:
kubectl -n <namespace> logs -l app.kubernetes.io/name=egress-proxy --tail=-1 | grep "egress denied"
| Reason | Meaning |
|---|---|
not_allowed |
The host and port are not on the allowlist. An IP address, which the proxy never admits, belongs in egress.directDestinations. |
private_address |
The name resolved to a loopback, unspecified or link-local address, or to a private address its entry does not allow. |
metadata |
The name resolved to a cloud metadata address. |
resolve_failed |
The name did not resolve. |
dial_failed |
The connection to the destination failed. |
upstream_error |
The exchange with the destination failed other than while connecting. |
capacity |
The replica holds its maximum number of open tunnels and forwarded requests (egress.proxy.maxTunnels). |
bad_request |
The destination host and port of the request cannot be read. |
unsupported |
The method is neither CONNECT nor an absolute-form HTTP request, or the request asks for a protocol upgrade, which is not forwarded. |
Your own egress controls. On Cilium or GKE Dataplane V2 you can write
host-name rules natively. Set egress.enabled: false to run no proxy and manage
the agents’ egress yourself: the chart then sets no proxy variable and renders no
egress NetworkPolicy, so you may set HTTP_PROXY, HTTPS_PROXY and NO_PROXY in
the agents’ env.
What it does not stop.
- A CNI that does not enforce NetworkPolicy, or
networkPolicy.enabled: false: the proxy variables only direct the agents’ own clients, and nothing stops a connection that skips the proxy.helm upgradewarns whennetworkPolicy.enabledis false. - Name resolution: the agents may resolve any name, so a DNS query can carry data to a name server.
- An allowed host: the proxy sees host names and ports, not the encrypted traffic. It cannot stop a client that opens a tunnel to an allowed host and then reaches another site through a shared network behind it, nor a request to an allowed provider made with another account’s key.
- Everything inside the release namespace, and the namespaces and ports the lists above open.
Requirements at a glance
- Kubernetes
>=1.27, Helm 3.14+. - PostgreSQL only, today. The
externalDatabase.*keys are deliberately engine-agnostic, but every migration and query assumes Postgres; MySQL is a roadmap item, not a supported option. - In-cluster encryption is the operator’s: the chart configures no mTLS; run
a service mesh (Istio, Linkerd) or CNI-level encryption if you need
encrypted pod-to-pod traffic. External datastore connections use whatever
transport you configure (
externalDatabase.sslMode: require, TLS endpoints for S3, etc.).