BoltMCP Installation Docs

Ingress & TLS

Expose BoltMCP publicly. Reference setup with NGINX Ingress Controller and automatic HTTPS certificates.

By default the BoltMCP chart doesn't manage cluster ingress. It expects you to provision your own ingress / gateway / load balancer that terminates TLS and routes the BoltMCP hostnames to the in-cluster services.

If your platform team handles ingress, hand them the hostnames table below and skip the rest of this page. The walkthrough that follows is a reference setup using NGINX Ingress Controller, cert-manager, Let's Encrypt, and the chart-managed Ingress. Adapt it for your environment, or replace it entirely with whatever ingress your cluster already runs.

What BoltMCP needs from your ingress

For every value of global.domain you set in values-prod.yaml, the chart configures the workloads to expect these public hostnames, terminating TLS, routed to these in-cluster Services:

Public hostnameServicePort
web.<domain>boltmcp-web3000
auth.<domain>boltmcp-keycloak8080
server.<domain>boltmcp-mcp-server3001
inspector.<domain>boltmcp-mcp-inspector6274

If you've overridden any of web.baseUrl, mcpServer.baseUrl, mcpInspector.baseUrl, or keycloak.baseUrl in your values, mirror those changes in your ingress (the chart-managed Ingress picks them up automatically).

Two NGINX-specific annotations matter:

  • nginx.ingress.kubernetes.io/proxy-buffer-size: "128k" - required for Keycloak's large authentication headers. NGINX's own default is 4k, which truncates them and breaks every login with upstream sent too big header.
  • nginx.ingress.kubernetes.io/proxy-body-size: "10m" - caps API spec uploads. NGINX's own default is 1m, which rejects larger specs with 413 Request Entity Too Large.

Both are BoltMCP requirements rather than site preferences, so the chart-managed Ingress applies them for you - don't repeat them in your values file. You only set them explicitly to choose a different limit, or when you're authoring your own Ingress manifest. To change one, set the same key in ingress.annotations: your value replaces the chart default, and any other annotations you supply are merged alongside it.

The equivalent settings exist on most ingress implementations; check your platform's docs.

If you're authoring your own Ingress manifest rather than using the chart-managed one, the chart package's examples/ingress directory contains a reference manifest to start from.

Reference Ingress Setup

The rest of this page walks through one common setup: NGINX Ingress Controller, DNS-based routing, and the chart-managed Ingress tying them together, followed by cert-manager for automatic TLS in Reference TLS Setup. Skip the controller and cert-manager steps if your cluster already has them.

Install NGINX Ingress Controller

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.externalTrafficPolicy=Local

On EKS - and especially EKS Auto Mode - the integrated AWS Load Balancer Controller provisions a LoadBalancer Service as internal by default, which leaves the ingress unreachable from the public internet (and from Let's Encrypt's HTTP-01 validation). Set the aws-load-balancer-scheme annotation so the NLB is internet-facing from the start:

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.externalTrafficPolicy=Local \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/aws-load-balancer-scheme"=internet-facing

The load balancer scheme is immutable. If you install without this annotation and add it later, the controller deletes the internal NLB and creates a fresh internet-facing one with a different hostname - so any DNS records you've already pointed at the old hostname must be updated, and you'll wait out their TTL while resolution converges. Setting it up front avoids that churn.

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.externalTrafficPolicy=Local
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace

Wait for the external IP:

kubectl get svc ingress-nginx-controller -n ingress-nginx -w

Expected output:

NAME                       TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)                      AGE
ingress-nginx-controller   LoadBalancer   xx.x.xxx.xx   x.xxx.xxx.xxx   80:32695/TCP,443:30691/TCP   2m8s
NAME                       TYPE           CLUSTER-IP    EXTERNAL-IP                         PORT(S)                      AGE
ingress-nginx-controller   LoadBalancer   xx.xxx.x.xx   xx-xx.eu-west-2.elb.amazonaws.com   80:30454/TCP,443:31623/TCP   2m8s
NAME                       TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)                      AGE
ingress-nginx-controller   LoadBalancer   xx.x.xxx.xx   x.xxx.xxx.xxx   80:32695/TCP,443:30691/TCP   2m8s
NAME                       TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx-controller   LoadBalancer   xx.xx.xxx.xx   xxx.xx.x.x    80:32127/TCP,443:31751/TCP   7s

Installing locally (e.g. Docker Desktop)? Skip to Enable the Chart-managed Ingress.

Reserve a Stable Address

Give the load balancer a stable address so your DNS records stay valid across restarts. Whether you need to do anything here depends on your cloud:

REGION=$(gcloud container clusters describe <cluster-name> \
  --format="get(location)" | sed 's/-[a-z]$//')

CURRENT_IP=$(kubectl get svc ingress-nginx-controller \
  -n ingress-nginx \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}')

gcloud compute addresses create boltmcp-ingress-ip \
  --addresses $CURRENT_IP \
  --region $REGION

Run straight after the EXTERNAL-IP first appears, this can fail with Invalid value for field 'resource.address': ... Specified IP address is already reserved. GKE holds the address while it finishes provisioning the load balancer. Wait a minute and re-run the same command - it succeeds once provisioning settles.

Confirm the address is reserved before moving on - until it is, the IP is still ephemeral and recreating the ingress-nginx Service would change it, invalidating the DNS records you create next:

gcloud compute addresses describe boltmcp-ingress-ip \
  --region $REGION \
  --format='value(address,status)'
# Should print your EXTERNAL-IP and IN_USE

The default ingress-nginx install provisions a Classic ELB, and its DNS hostname is already stable for the life of the Service - it survives pod, node, and controller restarts. There is nothing to reserve: note the hostname and point DNS at it with a CNAME in the next step.

You only need a literal static IP on EKS if something external must allowlist a fixed address, or you've overridden a BoltMCP service hostname to a zone apex (where CNAMEs aren't permitted - see the DNS step). In that case, provision a Network Load Balancer (NLB) with Elastic IPs instead of the default Classic ELB: install the AWS Load Balancer Controller, then recreate the controller Service as an NLB with the annotations service.beta.kubernetes.io/aws-load-balancer-type: external and service.beta.kubernetes.io/aws-load-balancer-eip-allocations set to one pre-allocated Elastic IP per public subnet (AZ). The EIP allocations must be set when the NLB is first created - they cannot be attached to an existing Classic ELB by annotation.

# Create a static public IP in the node resource group
NODE_RG=$(az aks show \
  --resource-group boltmcp-rg \
  --name boltmcp-cluster \
  --query nodeResourceGroup -o tsv)

az network public-ip create \
  --resource-group $NODE_RG \
  --name boltmcp-ingress-ip \
  --sku Standard \
  --allocation-method Static \
  --zone 1 2 3 \
  --location westeurope

The --location should match the region of your AKS node resource group. The create command's JSON response includes the assigned ipAddress - note it down. If you lose it, look it up again with az network public-ip show -g $NODE_RG -n boltmcp-ingress-ip --query ipAddress -o tsv.

Set the IP on the ingress controller:

helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --reuse-values \
  --set controller.service.loadBalancerIP=<static-ip>

On AKS - unlike GKE and EKS - the load balancer's external IP changes when you run this upgrade: Azure swaps the ephemeral IP for your reserved one. Update any in-progress DNS work to use the new IP.

Configure DNS

Point each BoltMCP subdomain at the load balancer. The record type depends on what the previous steps gave you, and the hostname values depend on whether your global.domain matches the DNS hosted zone.

  • If your load balancer is an IP address (GKE, AKS, or an EKS NLB+EIP), create A records.
  • If it's a DNS hostname (the default EKS Classic ELB), create CNAME records - a raw hostname can't go in an A record.

In the tables below, <lb-endpoint> is the EXTERNAL-IP you noted earlier (an IP or a hostname) and <record-type> is A for an IP or CNAME for a hostname.

BoltMCP's hostnames are always web., auth., server., and inspector. prefixed, so they're never a zone apex - a wildcard or per-service CNAME is always valid for the EKS hostname case. The apex restriction only matters if you've overridden a service URL to a bare apex domain; there, use your provider's ALIAS/ANAME record (or a Route 53 Alias) instead of a CNAME, or switch to an IP and an A record.

You are updating DNS records in a hosted zone which sits above global.domain. For example:

  • DNS hosted zone: example.com
  • BoltMCP global domain: boltmcp.example.com

If your DNS provider supports wildcard records and a wildcard at this label won't clash with other records in the zone, add a single record:

HostnameTypeValue
*.boltmcp<record-type><lb-endpoint>

Otherwise, create four explicit records:

HostnameTypeValue
web.boltmcp<record-type><lb-endpoint>
auth.boltmcp<record-type><lb-endpoint>
server.boltmcp<record-type><lb-endpoint>
inspector.boltmcp<record-type><lb-endpoint>

If your subdomain is different, replace "boltmcp" in the Hostnames accordingly.

You are updating DNS records in a hosted zone which matches global.domain. For example:

  • DNS hosted zone: example.com
  • BoltMCP global domain: example.com

If your DNS provider supports wildcard records and a wildcard at the zone root won't clash with other records in the zone, add a single record:

HostnameTypeValue
*<record-type><lb-endpoint>

Otherwise, create four explicit records:

HostnameTypeValue
web<record-type><lb-endpoint>
auth<record-type><lb-endpoint>
server<record-type><lb-endpoint>
inspector<record-type><lb-endpoint>

Verify propagation across all four subdomains:

for h in web auth server inspector; do
  printf "%s -> " "$h.<domain>"
  dig +short "$h.<domain>" @8.8.8.8 || echo "(none)"
done

Each line should resolve to your load balancer - directly to the IP for A records, or to the load balancer hostname (and, below it, the IPs it points to) for CNAME records. Replace <domain> with your global.domain value.

Enable the Chart-managed Ingress

Add an ingress block to your values file. The annotations differ depending on whether or not you're terminating TLS:

config/values-prod.yaml
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-staging

The cert-manager.io/cluster-issuer annotation points at the issuer you create in Reference TLS Setup below. Until that issuer exists the certificate stays pending, which is expected.

config/values-local.yaml
ingress:
  enabled: true
  className: nginx
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"

# Local install only (e.g. Docker Desktop): let in-cluster pods resolve the
# public hostnames to the ingress so server-side OIDC discovery reaches
# Keycloak. Set `ip` to the ingress controller's CLUSTER-IP.
global:
  hostAliases:
    - ip: "<ingress-nginx-controller CLUSTER-IP>"
      hostnames:
        - web.boltmcp.local
        - auth.boltmcp.local
        - server.boltmcp.local
        - inspector.boltmcp.local

For a local install, also add the browser-side hostnames to your workstation's /etc/hosts:

127.0.0.1 web.boltmcp.local auth.boltmcp.local server.boltmcp.local inspector.boltmcp.local

The chart's Ingress follows global.tls.enabled: on an HTTPS install it covers all BoltMCP hostnames with the boltmcp-tls Secret (name configurable via ingress.tls.secretName); on a plain-HTTP install it omits TLS entirely.

Apply the change using the same chart version and values file as the install (re-export HELM_REGISTRY_CONFIG and BOLTMCP_VERSION first if you're in a new shell):

helm upgrade boltmcp \
  oci://europe-west2-docker.pkg.dev/boltmcp-platform/boltmcp-alpha/charts/boltmcp \
  --version ${BOLTMCP_VERSION} \
  -n boltmcp \
  -f ./config/values-prod.yaml \
  --timeout 15m

Reference TLS Setup

These steps add automatic HTTPS to the chart-managed Ingress via cert-manager and Let's Encrypt. Skip this section entirely on a plain-HTTP install (global.tls.enabled: false), or if your cluster already terminates TLS in front of the ingress.

Install cert-manager

helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

Wait for all cert-manager pods to be running:

kubectl get pods -n cert-manager

Create a ClusterIssuer

Start with a staging issuer for testing (avoids Let's Encrypt rate limits):

kubectl apply -f ./config/cluster-issuer-staging.yaml
config/cluster-issuer-staging.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            class: nginx

Replace your-email@example.com with a mailbox you actually monitor. Let's Encrypt uses this address to warn you when a certificate is approaching expiry without having auto-renewed - it's your only signal that renewal is broken before the cert goes down.

Verify:

kubectl get clusterissuer
# READY should be True

With the staging issuer in place, the certificate referenced by the chart-managed Ingress (applied in Enable the Chart-managed Ingress above) can now be provisioned. Watch it:

kubectl get certificates -n boltmcp -w
# Wait for READY to become True

Switch to Production Certificates

Once everything works with staging certificates, create a production issuer:

kubectl apply -f ./config/cluster-issuer-production.yaml
config/cluster-issuer-production.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-production
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-production-account-key
    solvers:
      - http01:
          ingress:
            class: nginx

Again, replace your-email@example.com with a monitored mailbox so you receive Let's Encrypt's renewal-failure warnings.

Point the Ingress at the production issuer by updating the annotation in values-prod.yaml:

ingress:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-production

Run the same helm upgrade command as in the previous step, then delete the staging certificate Secret to trigger re-issuance:

kubectl delete secret boltmcp-tls -n boltmcp

Verify the new certificate:

kubectl get certificates -n boltmcp -w
# Wait for READY = True

Congratulations! You've successfully exposed BoltMCP to the public internet with TLS-secured ingress. Your cluster is now reachable at your configured hostnames with valid, auto-renewing certificates. Your browser should now show a trusted certificate without security warnings.

On this page