External CI Integration
Any external CI pipeline can integrate with OpenChoreo by calling the Workload API. Instead of using OpenChoreo's built-in CI (Argo Workflows), you can use your existing CI infrastructure to build container images and trigger deployments.
Overview
OpenChoreo supports two approaches for building components:
- Built-in CI - Argo Workflows managed by OpenChoreo
- External CI - Your own CI pipeline builds images and creates workloads via API
The integration point is a single REST endpoint:
POST /api/v1/namespaces/{namespaceName}/workloads
Authorization: Bearer <token>
Content-Type: application/json
The request body specifies Workload CR in JSON format. Any external CI pipeline that can build an image and make this API call can integrate with OpenChoreo. For the full schema and examples, see the Workload API Reference.
This guide covers the External CI approach, where:
- Your CI pipeline builds the container image
- Your CI calls the OpenChoreo API to create/update workloads
- OpenChoreo handles deployment, scaling, and observability
Steps 1–3 cover wiring an external CI pipeline to the Workload API, with worked examples for both Jenkins and GitHub Actions — the same approach applies to any CI tooling. Steps 4 and 5 cover surfacing build status back in the Backstage portal for Jenkins and GitHub Actions respectively; follow whichever matches your CI.
Prerequisites
- OpenChoreo installed and running
- Access to your identity provider (ThunderID IDP or configured OIDC provider)
- Jenkins configured and accessible
- Container registry accessible to both CI and OpenChoreo cluster
Step 1: Create a Service Account
External CI needs OAuth2 client credentials to obtain JWT tokens for authenticating with the OpenChoreo API.
Using ThunderID IDP (Default)
If you're using the bundled ThunderID IDP, create an OAuth2 application through the ThunderID console.
See Identity Configuration for ThunderID access details and default credentials.
1. Create an OAuth2 Application
- Open the ThunderID console at
<thunder-url>/console - Log in with your admin credentials (default:
admin/admin) - Create a new application and select Backend Service (server-to-server APIs) as the application type
- Copy the Client ID and Client Secret and store them securely as Jenkins credentials
2. Verify Token Generation
You can test the credentials by exchanging them for an access token:
curl -X POST "<thunder-url>/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=<your-client-id>" \
-d "client_secret=<your-client-secret>"
For long-running CI pipelines, configure a longer token validity period in the application settings within the ThunderID console.
Using Other Identity Providers
If you've configured a different OIDC provider, create a service account following your provider's documentation. The token must include claims that OpenChoreo can validate against your security configuration.
Step 2: Create Component with External CI
When creating a new component in Backstage:
- Navigate to Create in Backstage
- Select your component type
- For Deployment Source, choose "External CI"
- Optionally configure Jenkins for build visibility
- Complete the wizard
The component is created without a workload. Your CI pipeline will create workloads when builds complete.
Step 3: Configure Your CI Pipeline
Jenkins
Store the following as Jenkins credentials before using this pipeline:
| Credential ID | Type | Description |
|---|---|---|
workflows-credentials | Username with password | Client ID (username) and Client Secret (password) from Step 1 |
thunder-url | Secret text | ThunderID IDP URL (e.g., https://thunder.example.com) |
openchoreo-api-url | Secret text | OpenChoreo API URL (e.g., https://api.openchoreo.example.com) |
pipeline {
agent any
environment {
NAMESPACE = 'default'
PROJECT = 'my-project'
COMPONENT = 'my-service'
REGISTRY = 'registry.example.com'
}
stages {
stage('Build & Push') {
steps {
script {
env.IMAGE = "${REGISTRY}/${COMPONENT}:${BUILD_NUMBER}"
sh "docker build -t ${env.IMAGE} ."
sh "docker push ${env.IMAGE}"
}
}
}
stage('Deploy to OpenChoreo') {
steps {
withCredentials([
usernamePassword(
credentialsId: 'workflows-credentials',
usernameVariable: 'CLIENT_ID',
passwordVariable: 'CLIENT_SECRET'
),
string(credentialsId: 'thunder-url', variable: 'THUNDER_URL'),
string(credentialsId: 'openchoreo-api-url', variable: 'OPENCHOREO_API_URL')
]) {
sh '''
# Get access token
TOKEN=$(curl -sf -X POST "${THUNDER_URL}/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
| jq -r '.access_token')
# Create/update workload
curl -sf -X POST \
"${OPENCHOREO_API_URL}/api/v1/namespaces/${NAMESPACE}/workloads" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\\"containers\\":{\\"main\\":{\\"image\\":\\"${IMAGE}\\"}}}"
'''
}
}
}
}
}
GitHub Actions
The same two API calls work from a GitHub Actions workflow: build and push the image, then register the Workload with OpenChoreo. Store the OAuth client credentials and API/IDP URLs as repository or organization secrets before using this workflow:
| Secret | Description |
|---|---|
OPENCHOREO_CLIENT_ID | Client ID from Step 1 |
OPENCHOREO_CLIENT_SECRET | Client Secret from Step 1 |
THUNDER_URL | ThunderID IDP URL (e.g., https://thunder.example.com) |
OPENCHOREO_API_URL | OpenChoreo API URL (e.g., https://api.openchoreo.example.com) |
name: Build and deploy to OpenChoreo
on:
push:
branches: [main]
env:
NAMESPACE: default
PROJECT: my-project
COMPONENT: my-service
REGISTRY: registry.example.com
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push image
run: |
IMAGE="${REGISTRY}/${COMPONENT}:${GITHUB_SHA::7}"
echo "IMAGE=${IMAGE}" >> "${GITHUB_ENV}"
docker build -t "${IMAGE}" .
docker push "${IMAGE}"
- name: Register the workload with OpenChoreo
env:
CLIENT_ID: ${{ secrets.OPENCHOREO_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.OPENCHOREO_CLIENT_SECRET }}
THUNDER_URL: ${{ secrets.THUNDER_URL }}
OPENCHOREO_API_URL: ${{ secrets.OPENCHOREO_API_URL }}
run: |
set -euo pipefail
# 1. Get an access token (client_credentials grant)
TOKEN=$(curl -sf -X POST "${THUNDER_URL}/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
| jq -r '.access_token')
# 2. Build the Workload CR payload (image only; add endpoints and
# configurations here, or generate it from a workload.yaml — see note below)
WORKLOAD_NAME="${COMPONENT}"
cat > workload-cr.json <<EOF
{
"metadata": { "name": "${WORKLOAD_NAME}", "namespace": "${NAMESPACE}" },
"spec": {
"owner": { "projectName": "${PROJECT}", "componentName": "${COMPONENT}" },
"container": { "image": "${IMAGE}" }
}
}
EOF
# 3. Create the Workload; on HTTP 409 (already exists) fall back to PUT
CODE=$(curl -s -o resp.json -w '%{http_code}' -X POST \
"${OPENCHOREO_API_URL}/api/v1/namespaces/${NAMESPACE}/workloads" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d @workload-cr.json)
if [ "${CODE}" = "409" ]; then
curl -sf -X PUT \
"${OPENCHOREO_API_URL}/api/v1/namespaces/${NAMESPACE}/workloads/${WORKLOAD_NAME}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d @workload-cr.json
elif [ "${CODE}" -lt 200 ] || [ "${CODE}" -ge 300 ]; then
echo "Workload create failed (HTTP ${CODE}):"; cat resp.json; exit 1
fi
# 4. Annotate the WorkflowRun so the portal can display the build's
# image and workload configuration. RUN_NAME is the WorkflowRun
# created for this build (e.g. passed in as a workflow input).
curl -sf -X GET \
"${OPENCHOREO_API_URL}/api/v1/namespaces/${NAMESPACE}/workflowruns/${RUN_NAME}" \
-H "Authorization: Bearer ${TOKEN}" -o workflowrun.json
jq --rawfile wl workload-cr.json \
'.metadata.annotations["openchoreo.dev/workload"] = ($wl | rtrimstr("\n"))
| .metadata.annotations["openchoreo.dev/workload-from-source"] = "false"' \
workflowrun.json > workflowrun-updated.json
curl -sf -X PUT \
"${OPENCHOREO_API_URL}/api/v1/namespaces/${NAMESPACE}/workflowruns/${RUN_NAME}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d @workflowrun-updated.json
Steps 2–4 above are exactly what the built-in generate-workload workflow template does. For the full payload shape (endpoints, configurations), generating the CR from a workload.yaml descriptor with occ workload create, and the meaning of the openchoreo.dev/workload and openchoreo.dev/workload-from-source annotations, see Workload Generation and the Workload API Reference.
Step 4: Enable Jenkins Visibility in Backstage
OpenChoreo Backstage includes a built-in Jenkins plugin that displays build status and history directly in the portal.
Enable Jenkins Plugin (Administrator)
The Jenkins plugin is always installed. Enabling it requires two things: storing the Jenkins API key in the Backstage secret, and setting the connection details via Helm values.
Store the Jenkins API Key
The API key is read from the jenkins-api-key key in the Backstage credentials secret (referenced by backstage.secretName). Add it the same way other Backstage secrets are managed.
- External Secret (Recommended)
- Direct Secret
If you followed the k3d setup or on your environment guide, the jenkins-api-key is already present in your ClusterSecretStore and ExternalSecret with a placeholder value. Update it in your secret provider with a real Jenkins API token.
For OpenBao (default local dev setup), update the secret value:
kubectl exec -n openbao openbao-0 -- sh -c '
export BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN=root
bao kv put secret/backstage-jenkins-api-key value="your-real-jenkins-api-key"
'
Then trigger the ExternalSecret to sync the updated value:
kubectl annotate externalsecret backstage-secrets \
-n openchoreo-control-plane \
force-sync=$(date +%s) --overwrite
Restart Backstage to pick up the new secret:
kubectl rollout restart deployment/backstage -n openchoreo-control-plane
If you manage the Backstage secret directly without External Secrets Operator:
kubectl create secret generic backstage-secrets \
-n openchoreo-control-plane \
--from-literal=backend-secret="your-backend-secret" \
--from-literal=client-secret="your-client-secret" \
--from-literal=jenkins-api-key="your-real-jenkins-api-key" \
--dry-run=client -o yaml | kubectl apply -f -
Restart Backstage to pick up the new secret:
kubectl rollout restart deployment/backstage -n openchoreo-control-plane
Enable Jenkins in Helm Values
Set the Jenkins base URL and username. The API key is picked up from the secret automatically.
backstage:
externalCI:
jenkins:
enabled: true
baseUrl: "https://jenkins.example.com"
username: "admin"
Add Jenkins Annotation to Components
Once the plugin is enabled, add the Jenkins annotation to your components to display build status.
- Navigate to your component in Backstage
- Click the context menu (...) and select Edit Annotations
- Add the annotation:
jenkins.io/job-full-name=/job/my-org/job/my-service - Click Save
What You'll See
When configured correctly:
- Overview Page: A Jenkins status card appears showing recent build status
- Jenkins Tab: A dedicated tab shows detailed build history and logs
You can also configure Jenkins annotations during component creation by selecting Jenkins in the wizard.
Troubleshooting Jenkins Visibility
Jenkins tab shows but API calls fail:
- Check
baseUrlandusernameare correct in Helm values - Verify the
jenkins-api-keyvalue in your Backstage secret is a valid Jenkins API token - Ensure Jenkins is accessible from the Kubernetes cluster
- Verify the job path in the annotation matches the actual Jenkins job
Jenkins status cards not showing on Overview:
- Cards only appear when the entity has a Jenkins annotation
- If an external CI annotation is present, it replaces the OpenChoreo Workflows card
- Check browser console for any JavaScript errors
Step 5: Enable GitHub Actions Visibility in Backstage
OpenChoreo Backstage includes a built-in GitHub Actions plugin that displays workflow_run history directly in the portal. Once configured, a Component's CI/CD tab lists recent runs for the annotated repository.
The GitHub Actions card authenticates each portal user through a GitHub OAuth App — this is how a user who can see a private repository on GitHub sees its runs in the portal. The optional backend token in section 5.4 is a separate credential used only by Backstage backend plugins (catalog ingestion, scaffolder, TechDocs); it is not what the card reads. Set up the OAuth App first — without it the card cannot fetch runs even with a valid token.
5.1 Create a GitHub OAuth App
In GitHub, go to Settings → Developer settings → OAuth Apps → New OAuth App (use an organization-owned App for shared portals):
| Field | Value |
|---|---|
| Homepage URL | Your portal base URL, e.g. http://openchoreo.localhost:8080 |
| Authorization callback URL | <portal-base-url>/api/auth/github/handler/frame, e.g. http://openchoreo.localhost:8080/api/auth/github/handler/frame |
Record the Client ID and generate a Client secret. For GitHub Enterprise Server, create the OAuth App on your GHES instance instead of github.com.
The callback URL must match the portal base URL exactly (scheme, host, and port). A mismatch makes GitHub reject the login with a redirect_uri error.
5.2 Store the OAuth credentials
The OAuth client secret is read from the github-oauth-client-secret key in the Backstage credentials secret (referenced by backstage.secretName). Add it the same way other Backstage secrets are managed.
- External Secret (Recommended)
- Direct Secret
If you followed the k3d setup or on your environment guide, backstage-secrets is owned by External Secrets Operator (creationPolicy: Owner). Do not patch the Kubernetes Secret directly — ESO overwrites it on the next sync. Seed the values into your secret backend and map them through the ExternalSecret instead.
Seed the OpenBao KV (the convention is secret/backstage-<secretKey>, value under the value property):
kubectl exec -n openbao openbao-0 -- sh -c '
export BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN=root
bao kv put secret/backstage-github-oauth-client-secret value="your-real-client-secret"
'
The client secret is sensitive. Read it into a variable rather than pasting it inline — inline values land in your shell history and in the kubectl exec process args (visible via ps and audit logs). Rotate any secret exposed that way.
Add the key to the backstage-secrets ExternalSecret so ESO maps it onto the Kubernetes Secret. /spec/data/- appends, so run this once — re-running duplicates the entry:
kubectl patch externalsecret backstage-secrets -n openchoreo-control-plane --type=json -p='[
{"op":"add","path":"/spec/data/-","value":{"secretKey":"github-oauth-client-secret","remoteRef":{"key":"backstage-github-oauth-client-secret","property":"value"}}}
]'
Force a re-sync rather than waiting for the refreshInterval:
kubectl annotate externalsecret backstage-secrets \
-n openchoreo-control-plane \
force-sync=$(date +%s) --overwrite
If you manage backstage-secrets directly (no External Secrets Operator), add the key to that Secret. Export the value first so the command is copy-paste-safe:
export GH_OAUTH_CLIENT_SECRET="your-real-client-secret"
kubectl -n openchoreo-control-plane patch secret backstage-secrets --type=json -p='[
{"op":"add","path":"/data/github-oauth-client-secret","value":"'"$(printf %s "$GH_OAUTH_CLIENT_SECRET" | base64 | tr -d '\n')"'"}
]'
5.3 Enable GitHub Actions in Helm Values
Set the integration on and pass the OAuth Client ID (the client secret comes from the Secret you stored in 5.2). The chart then renders the auth.providers.github block and the integrations.github config automatically.
- Public GitHub (github.com)
- GitHub Enterprise Server
backstage:
externalCI:
githubActions:
enabled: true
oauth:
clientId: "your-oauth-client-id"
The defaults (host: github.com, empty apiBaseUrl) are sufficient.
backstage:
externalCI:
githubActions:
enabled: true
host: "ghe.example.com"
apiBaseUrl: "https://ghe.example.com/api/v3"
oauth:
clientId: "your-oauth-client-id"
Both host and apiBaseUrl must be set for GHES — the plugin uses host for git URLs and apiBaseUrl for API calls.
Restart Backstage to pick up the new configuration:
kubectl rollout restart deployment/backstage -n openchoreo-control-plane
5.4 (Optional) Provision a GitHub backend token
Skip this unless you also use the Backstage backend GitHub integration (catalog ingestion, scaffolder git operations, TechDocs). The Actions card itself does not need it — it uses the per-user OAuth flow above.
- For github.com: create a fine-grained personal access token scoped to the relevant repositories with
Actions: ReadandMetadata: Readpermissions. - For org-wide / production: create a GitHub App, install it on the org, and pass the installation token — App tokens scale better and don't expire with the user that created them.
- For GHES: create the token on your GHES instance (the
host/apiBaseUrlfrom 5.3 already point Backstage at it).
Store it under the github-actions-token key the same way as 5.2 (OpenBao path secret/backstage-github-actions-token, or a direct Secret patch on /data/github-actions-token). The key is declared optional: true on the Deployment, so Backstage starts whether or not it is present.
Add the GitHub Annotation to Components
For any Component whose workflows you want to see, set the github.com/project-slug annotation:
apiVersion: openchoreo.dev/v1alpha1
kind: Component
metadata:
name: my-service
annotations:
github.com/project-slug: my-org/my-service
spec:
# ...
This is the same annotation the upstream @backstage/plugin-github-actions plugin reads.
The card reads this annotation from the Backstage catalog entity, not from the OpenChoreo Component CR directly. If your catalog sync does not copy the CR annotation onto the entity, set github.com/project-slug on the catalog entity itself.
What You'll See
When configured correctly:
- CI/CD Tab: A dedicated tab lists recent workflow runs for the annotated repository.
- The first time you open it, complete the one-time GitHub login when the popup appears.
Troubleshooting GitHub Actions Visibility
The card shows a sign-in prompt that fails, or /api/auth/github/start returns 404 No auth provider registered for 'github':
- The GitHub OAuth provider is not registered. Complete section 5.1–5.3: create the OAuth App, set
oauth.clientId, storegithub-oauth-client-secret, and restart Backstage.
The login popup opens but errors with a redirect_uri mismatch:
- The OAuth App's callback URL must be exactly
<portal-base-url>/api/auth/github/handler/frame(scheme, host, and port matching the URL the browser uses).
The CI/CD tab shows no runs even though the workflow ran:
- Verify the
github.com/project-slugannotation is on the Backstage catalog entity and matches the repository slug exactly (<org>/<repo>, case-sensitive). - For a private repository, the signed-in user must have access to it on GitHub and must have approved the
reposcope during the OAuth consent.
GitHub Enterprise Server users see Cannot find host errors:
- Both
hostandapiBaseUrlmust be set. Setting only one causes the other to fall back to github.com defaults.
Troubleshooting
401 Unauthorized
- Token expired: Get a new token using client credentials
- Invalid token: Verify your
client_idandclient_secret - Wrong JWKS: Ensure ThunderID/IDP URL is correctly configured in OpenChoreo
404 Not Found
- Component doesn't exist: Create the component first in Backstage
- Wrong path: Verify namespace, project, and component names match exactly
403 Forbidden
- Missing permissions: Service account may lack required roles
- Namespace restriction: Check if service account has access to the target namespace
Connection Refused
- API not accessible: Verify
OPENCHOREO_API_URLis reachable from CI runner - Firewall rules: Ensure CI runners can reach the OpenChoreo API endpoint
Debug Tips
-
Test token generation independently:
curl -v -X POST "$THUNDER_URL/oauth2/token" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" -
Test API connectivity with a simple GET:
curl -v -H "Authorization: Bearer $TOKEN" \
"$OPENCHOREO_API_URL/api/v1/namespaces" -
Check workload status after creation:
curl -H "Authorization: Bearer $TOKEN" \
"$OPENCHOREO_API_URL/api/v1/namespaces/$NAMESPACE/workloads?component=$COMPONENT"
Best Practices
- Rotate credentials regularly: Set up credential rotation for service accounts
- Use short-lived tokens: Configure appropriate token validity periods
- Implement retry logic: Handle transient API failures gracefully
- Tag images consistently: Use git SHAs or semantic versions for traceability
- Secure secrets: Use Jenkins Credentials for storing sensitive values
- Monitor deployments: Set up alerts for failed workload creations