Deploy on Kubernetes¶
This guide deploys the API Portal & MCP Hub in production on Kubernetes with the api-portal Helm chart. It covers every step from the prerequisites to upgrades, with the Helm values for each one. To deploy on Red Hat OpenShift, see Deploy on OpenShift.
For what each step requires and why, independent of where you run the portal, see the Overview and the Production Readiness pages: Security Hardening, Database Configuration, Resources & Scaling, and Control Plane Connection.
Prerequisites¶
| Tool | Requirement |
|---|---|
kubectl |
Configured against your target cluster |
helm |
Version 3+ |
openssl |
Available in your local shell, to generate keys |
| cert-manager | Installed in the cluster, if the chart issues the portal's certificate (the default). Not needed if you bring your own certificate, as described in Step 2. |
| Database | An external, highly available PostgreSQL or SQL Server the cluster can reach |
The api-portal chart is the product package; values for the portal itself nest under the api-portal-ui key. It also carries the Platform API as an optional dependency, on by default for the local-auth quickstart. A deployment that authenticates against an identity provider doesn't need it. With it off, the chart refuses to render unless api-portal-ui.config.platformApi.baseUrl names an external Platform API, so point it at the Platform API that fronts your gateways, the one you register as the webhook subscriber in Control Plane Connection. The portal calls that URL only for local authentication:
platform-api:
enabled: false
api-portal-ui:
config:
platformApi:
baseUrl: https://<PLATFORM_API_HOST>:<PORT>
The examples in this guide add to a values file named values-production.yaml.
The public 1.0.0 image serves the portal without /api-portal
This guide's URLs and routing use the portal's /api-portal path, which the update-level images, such as 1.0.0.7, serve. Update levels are available with a WSO2 subscription. The public 1.0.0 image, the chart's default, serves the portal at the root path instead. If you run that image, drop /api-portal from callbackUrl and logoutRedirectUri in Step 3 and from your routing in Step 8.
Step 1: Provision the secrets¶
The chart never creates or embeds secret values, and it refuses to render without them, so this comes first. Provision them with the bundled generate-secrets.sh:
If you'll use PostgreSQL or SQL Server (Step 5), run it this way instead. The script writes the database password into the portal's Secret only when it creates that Secret, so the password has to be in APIP_AP_DATABASE_PASSWORD on this first run. read -rsp keeps it off the screen and out of your shell history:
read -rsp 'Database password: ' APIP_AP_DATABASE_PASSWORD && echo
export APIP_AP_DATABASE_PASSWORD
./generate-secrets.sh api-portal my-release
unset APIP_AP_DATABASE_PASSWORD
It writes values-secrets.yaml, naming each Secret it created along with the has* flags for whichever optional keys it provisioned. Keep that file alongside your own values and pass both at install time. It points the release at the Secret it wrote:
Rendering fails if existingSecret is unset, so there is no path to starting a release without them.
The optional keys are wired only when the matching flag is true — secrets.hasIdpClientSecret, secrets.hasPublicKey — and generate-secrets.sh sets these in the values file it writes. Referencing one Secret from the release guarantees that every replica uses the same encryption key and session secret. See Security Hardening → Secrets.
Step 2: Configure TLS¶
api-portal-ui:
tls:
certificateProvider: cert-manager # cert-manager | secret | none
mountPath: /app/certs
| Provider | Use when |
|---|---|
cert-manager |
cert-manager runs in the cluster. Set tls.certManager.issuerRef, commonName, and dnsNames to your real hostname — the shipped defaults point at devportal.localhost and are development values. |
secret |
You hold a certificate already. Set tls.secret.name and, if your keys differ, certKey / keyKey. |
none |
A TLS-terminating proxy or ingress fronts the portal. The listener then serves plain HTTP. |
selfSigned is deliberately not supported — the image has no in-container certificate generation, so the chart rejects it at render time rather than starting a pod that can't serve HTTPS. See Security Hardening → TLS configuration.
Step 3: Configure authentication¶
api-portal-ui:
config:
auth:
idp:
issuer: https://idp.example.com/oauth2/token
authorizationUrl: https://idp.example.com/oauth2/authorize
tokenUrl: https://idp.example.com/oauth2/token
userInfoUrl: https://idp.example.com/oauth2/userinfo
jwksUrl: https://idp.example.com/oauth2/jwks
clientId: <portal-client-id>
callbackUrl: https://portal.example.com/api-portal/<org-handle>/callback
logoutUrl: https://idp.example.com/oidc/logout
logoutRedirectUri: https://portal.example.com/api-portal/<org-handle>
scope: "openid profile email"
The chart renders auth.mode=idp once idp.clientId is set; the client secret comes from the Secret.
logoutUrl is the identity provider's logout endpoint, and logoutRedirectUri is where the identity provider returns the user after logout. Both callbackUrl and logoutRedirectUri must sit under the portal's /api-portal mount and match what you registered in the identity provider. See Security Hardening → Authentication.
Step 4: Configure authorization¶
Keep authorization on, in the default role mode:
See Security Hardening → Authorization for what each mode does.
Step 5: Configure the database¶
Create the database and apply the schema first, as described in Database Configuration.
api-portal-ui:
config:
database:
driver: postgres # sqlite | postgres | mssql
host: postgres.example.com
port: 5432
database: api_portal # the database name, rendered into config as `name`
user: api_portal
sslmode: verify-full # disable | verify-full
sslRootCert: /etc/api-portal/db-ca/ca.pem
persistence:
enabled: false # the database holds the data now
deployment:
# Mounts the database CA that sslRootCert names
extraVolumes:
- name: db-ca
configMap:
name: postgres-ca
extraVolumeMounts:
- name: db-ca
mountPath: /etc/api-portal/db-ca
readOnly: true
Two key names catch people out: the database name goes in database, not name, and sslmode is all lowercase.
persistence.enabled: false removes the portal's SQLite volume, which an external database replaces. Leave it on and every replica mounts the same ReadWriteOnce claim, so a second replica on another node can't start.
sslRootCert names the CA that signed your database server's certificate. The chart doesn't mount that file, so the values above mount it from a ConfigMap. Create the ConfigMap before you deploy:
Without the file, the portal stops at startup with an ENOENT: no such file or directory error that names the missing file.
Switching driver off sqlite is also what unlocks scaling — the chart refuses to render an autoscaler on a database that can't be shared.
The password never appears in a values file. It comes from the release's Secret, under the key APIP_AP_DATABASE_PASSWORD, which is also the name of the environment variable the container receives. If you exported it before running generate-secrets.sh in Step 1, it's already there.
If you ran Step 1 without it, delete the portal's Secret and run Step 1 again with the password exported. Nothing uses the Secret until you deploy in Step 10, so regenerating it loses nothing:
kubectl -n api-portal delete secret my-release-api-portal-ui-secrets
read -rsp 'Database password: ' APIP_AP_DATABASE_PASSWORD && echo
export APIP_AP_DATABASE_PASSWORD
./generate-secrets.sh api-portal my-release
unset APIP_AP_DATABASE_PASSWORD
If the portal pods stop in CreateContainerConfigError with a message about APIP_AP_DATABASE_PASSWORD, the key is missing from the Secret.
Size the connection pool against your replica count, as described in Database Configuration → Connection pool tuning:
api-portal-ui:
config:
database:
maxOpenConns: 25
minOpenConns: 2
poolIdleTimeoutMs: 10000
poolConnectionTimeoutMs: 30000
poolRequestTimeoutMs: 30000
Step 6: Tighten the try-it proxy and upload ceilings¶
Tighten the try-it proxy's development-oriented defaults:
api-portal-ui:
config:
tryout:
allowHttpEndpoints: false # default true — production should allow only https://
allowPrivateEndpoints: false # default; leave false unless the gateway is genuinely private
tlsSkipVerify: false # development only
timeoutMs: 15000
maxRequestBytes: 1048576
maxResponseBytes: 5242880
The upload ceilings ship with these defaults. Raise them only deliberately:
api-portal-ui:
config:
uploads:
maxBytes: 10485760 # 10 MiB per upload, or per entry inside an archive
maxTotalBytes: 52428800 # 50 MiB total extracted size per archive
maxZipEntries: 500
maxDepth: 10
See Security Hardening → Try-it proxy and Upload ceilings for what each setting guards.
Step 7: Configure replicas and resources¶
Replica count¶
Resources¶
Set requests on every production pod. Without a CPU request the scheduler can't place pods sensibly and the HPA can't compute utilization at all:
Prefer leaving limits.cpu unset. A CPU limit throttles rather than kills, and throttling a single-threaded event loop turns a brief burst into queued latency across every request that pod is serving. See Resources & Scaling → Resources.
Spread across failure domains¶
api-portal-ui:
deployment:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: api-portal-ui
DoNotSchedule is deliberate: it leaves a pod Pending rather than co-locating it, which surfaces an under-provisioned cluster as a visible unscheduled pod instead of a silently fragile deployment. Use ScheduleAnyway only if you would rather have a degraded placement than a Pending pod.
For spreading across availability zones, add topology.kubernetes.io/zone as a second constraint.
Autoscaling and disruption¶
api-portal-ui:
hpa:
enabled: true
minReplicas: 2
maxReplicas: 3
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: "" # set a number to also scale on memory
podDisruptionBudget:
enabled: true
minAvailable: 1
The chart is fail-fast: it refuses to render an autoscaler for a deployment whose database cannot be shared, rather than scaling one into corruption.
Two prerequisites, both easy to miss:
deployment.resources.requests.cpumust be set. Utilization is a ratio against the request; with no request there is nothing to divide by, and the HPA reports unknown metrics indefinitely.- metrics-server must be running in the cluster.
For the PodDisruptionBudget, set exactly one of minAvailable or maxUnavailable, and enable it only at two or more replicas. With a single pod, minAvailable: 1 blocks node drains entirely — the cluster can never evict the only pod, so maintenance stalls.
Step 8: Front the portal¶
The chart deploys a Service, not an Ingress. Bring your own ingress controller or load balancer, and route /api-portal/* through without stripping the prefix, as described in Overview → Fronting the portal.
Set the public base URL:
Sessions live in the shared database, so no sticky-session configuration is required.
Step 9: Configure health probes¶
Point both probes at /health:
api-portal-ui:
deployment:
livenessProbe:
httpGet: { path: /health, port: http, scheme: HTTPS }
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /health, port: http, scheme: HTTPS }
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
If you set tls.certificateProvider: none, change both probes' scheme from HTTPS to HTTP — otherwise every probe fails and pods never become ready.
Step 10: Deploy¶
The api-portal chart pulls its components as OCI dependencies, so fetch them before installing:
Then install with your production values last, so they win:
helm install my-release ./api-portal-helm-chart \
--namespace api-portal --create-namespace \
-f values-secrets.yaml \
-f values-production.yaml
Render before you install if you want to see what a values change actually produces — this is also where the chart's fail-fast guards fire, before anything is created:
helm template my-release ./api-portal-helm-chart \
-f values-secrets.yaml -f values-production.yaml > /dev/null
A missing secrets.existingSecret or tls.certificateProvider: selfSigned fails here with an explicit message.
Step 11: Verify¶
Confirm every instance is serving, not just that the deploy command succeeded.
kubectl -n api-portal get pods -l app.kubernetes.io/name=api-portal-ui
kubectl -n api-portal get hpa,pdb
Check the pods landed on different nodes — a topology-spread constraint that couldn't be satisfied leaves a pod Pending:
Confirm the portal is serving, from inside the cluster:
kubectl -n api-portal exec deploy/my-release-api-portal -- \
wget -qO- --no-check-certificate https://localhost:9543/health
Use curl with the public 1.0.0 image
API Portal images from update level 1.0.0.1 onward ship wget but not curl, so this guide uses wget. The public 1.0.0 image ships curl but not wget. If you run that image, use this command instead:
Finally, verify that sessions are shared and that only one instance dispatches each event, as described in Verify & Upgrade.
Step 12: Register the webhook subscriber¶
Register the subscriber that propagates credentials to your gateways, as described in Control Plane Connection.
Upgrade¶
helm dependency update ./api-portal-helm-chart
helm diff upgrade my-release ./api-portal-helm-chart \
-f values-secrets.yaml -f values-production.yaml # requires the helm-diff plugin
helm upgrade my-release ./api-portal-helm-chart \
-f values-secrets.yaml -f values-production.yaml
Always pass the secrets values file. Omitting it on an upgrade fails the render rather than silently dropping the Secret reference — intended behavior, but it does mean an upgrade command is no shorter than an install command.
Roll back a bad release with helm rollback my-release.
Before you upgrade, read the two cautions in Verify & Upgrade → Upgrade.
Local authentication (development only)¶
Production deployments delegate login to an identity provider. For a development or demo deployment that uses the built-in login form, point the portal at the Platform API bundled with the chart. Turn it back on, which replaces the platform-api.enabled: false from Prerequisites:
platform-api:
enabled: true
api-portal-ui:
config:
platformApi:
baseUrl: "" # "" derives the in-cluster URL
insecure: false
auth:
publicKeyPath: /etc/devportal/keys/jwt_public.pem
secrets:
hasPublicKey: true
baseUrl must use the in-cluster Service name and container port, because the portal connects over the cluster network rather than through a published port or ingress. Pointing it at an externally published address makes every login fail while the Platform API itself looks healthy.
generate-secrets.sh copies the public key out of the Platform API's own Secret, so the two stay on the same keypair. Set hasPublicKey: false when you have no local-auth path at all. See Control Plane Connection → Local authentication.
Trust the Platform API certificate¶
The portal calls the Platform API inside the cluster at https://my-release-platform-api:9243 and verifies its certificate. The chart's default certificate is self-signed and issued for platform-api.localhost, so with insecure: false every login fails. Public certificate authorities (CAs) don't issue certificates for an in-cluster Service name, so issue this one from a private CA and make the portal trust that CA:
-
Create the private CA with cert-manager. A self-signed issuer signs the CA certificate, and the
internal-caissuer then signs certificates with it. To use a private CA you already run, skip this step and reference its issuer in step 3.# internal-ca.yaml apiVersion: cert-manager.io/v1 kind: Issuer metadata: name: selfsigned-root spec: selfSigned: {} --- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: internal-ca spec: isCA: true commonName: internal-ca secretName: internal-ca privateKey: algorithm: ECDSA size: 256 issuerRef: name: selfsigned-root kind: Issuer --- apiVersion: cert-manager.io/v1 kind: Issuer metadata: name: internal-ca spec: ca: secretName: internal-ca -
Copy the CA certificate, without its private key, into a ConfigMap that the portal mounts:
-
Issue the Platform API's certificate from that CA for the Service name, and point the portal at the CA. The portal runs on Node.js, which adds the certificates in
NODE_EXTRA_CA_CERTSto the ones it trusts:platform-api: tls: certManager: createIssuer: false issuerRef: name: internal-ca kind: Issuer commonName: my-release-platform-api dnsNames: - my-release-platform-api api-portal-ui: config: platformApi: insecure: false deployment: extraEnv: - name: NODE_EXTRA_CA_CERTS value: /etc/api-portal/platform-api-ca/ca.pem extraVolumes: - name: platform-api-ca configMap: name: internal-ca extraVolumeMounts: - name: platform-api-ca mountPath: /etc/api-portal/platform-api-ca readOnly: trueextraVolumesandextraVolumeMountsare lists, and Helm replaces a list instead of merging it. If you also mount the database CA, as described in Step 5, put both volumes in oneextraVolumeslist and both mounts in oneextraVolumeMountslist. -
Apply the values with the
helm upgradecommand in Upgrade, then restart the Platform API. It reads its certificate only at startup, so it keeps serving the previous one until it restarts:
Related¶
- Deploy on OpenShift: the same chart on Red Hat OpenShift
- Overview: architecture, topology, and the production checklist
- Security Hardening: what the secrets, TLS, and authentication settings protect
- Database Configuration: creating the database, applying the schema, and sizing the pool
- Resources & Scaling: why two replicas is the floor
- Control Plane Connection: the post-install webhook registration