This page is generated from devops-agent/eks-ingress-migration/references/alb-migration.md. Edit the source, not this page.
ALB Controller Migration Path
Rating model: Express every finding as Impact 0–5 using the Impact Indicator rubric, weighing three dimensions in priority order: (1) business logic / revenue — the live traffic at stake · (2) security / reputation · (3) effort to remediate. Effort is NOT a severity driver — a fix being easy or hard never moves the score (it depends on who implements it). Presence is decided by estate state — absent controller / empty estate / orphaned dead config = non-event (0); a broken controller is tech debt (1) with zero bound routes or a suspected active outage (flagged outside the score) with bound routes; a running controller with a control-plane CVE counts even at zero routes. See
ingress-discovery.mdfor the full presence/stacking rules. Band mapping is a starting point — 🟢 0 / 🟡 1–2 / 🟠 3–4 / 🔴 5 — but the Impact Indicator criteria set the final score (e.g. an easy-to-deploy prerequisite stays 🟡 low even if it blocks a path). All checks are read-only (Kubernetes APIget/list, AWSDescribe/List).
Purpose
Guide migration from NGINX Ingress Controller to AWS Load Balancer Controller (ALB Ingress), converting all NGINX-specific annotations to their ALB equivalents.
This is the first hop of the complete path. After NGINX → LBC ALB Ingress (this file), the second hop LBC Ingress → Gateway API can be automated with the official
lbc-migratetoolkit — seereferences/lbc-migrate-toolkit.md. Full path: NGINX Ingress → LBC ALB Ingress → Gateway API.
When to Recommend This Path
- Customer wants to stay on Ingress API (not ready for Gateway API)
- Customer needs ALB features (WAF, Cognito/OIDC, Shield)
- Customer has AWS Transform (ATX) access → fully automated migration
- Customer is on Classic Load Balancer via NGINX and wants ALB
Annotation Mapping: NGINX → ALB
Core Changes (Every Ingress)
| Step | Before (NGINX) | After (ALB) |
|---|---|---|
| IngressClass | ingressClassName: nginx | ingressClassName: alb |
| Deprecated class | kubernetes.io/ingress.class: "nginx" annotation | Remove annotation, add spec.ingressClassName: alb |
| Scheme | (implicit: CLB) | alb.ingress.kubernetes.io/scheme: internet-facing or internal |
| Target type | (implicit: NodePort) | alb.ingress.kubernetes.io/target-type: ip |
URI Rewrite
| Before (NGINX) | After (ALB) |
|---|---|
nginx.ingress.kubernetes.io/use-regex: "true" | Remove |
nginx.ingress.kubernetes.io/rewrite-target: /$2 | alb.ingress.kubernetes.io/transforms.<svc> with url-rewrite JSON |
path: /something(/|$)(.*) + pathType: ImplementationSpecific | path: /something + pathType: Prefix |
Transforms JSON format:
alb.ingress.kubernetes.io/transforms.<service-name>: |
[
{
"type": "url-rewrite",
"urlRewriteConfig": {
"rewrites": [
{
"regex": "^\\/something(\\/|$)(.*)$",
"replace": "/$2"
}
]
}
}
]
Rules:
-
Requires LBC ≥ v2.14.1 — the
transforms.<svc>annotation was introduced in v2.14.1 (2025-10-17). It is absent from v2.14.0 and from every v2.13.x line. Below the floor it is an unknown annotation and is silently ignored — no rewrite happens and traffic reaches the backend with the original path. This is the one hard controller floor on the ALB Ingress path. Do not confuse this Ingress annotation with the Gateway APIHTTPRouteURLRewritefilter, a separate feature that landed in v2.15.0. -
<service-name>must match the backend service name inspec.rules -
Forward slashes in regex must be escaped as
\\/in JSON — exactly one level. Over-escaping to\\\\/makes the pattern require a literal backslash, so it matches nothing and the rewrite silently never fires (the vendored ATX TD has this bug — seeatx-guide.md). -
Keep the separator group — do not "simplify" it away. Mirror NGINX's
(/|$)and keep the payload in$2. The three forms compared below:NGINX source path: /something(/|$)(.*) rewrite-target: /$2ALB, correct regex: ^\/something(\/|$)(.*)$ replace: /$2 <- use thisALB, over-simplified regex: ^\/something\/(.*)$ replace: /$1 <- silently wrongRequest NGINX ALB correct ALB over-simplified /something//✅no match → forwarded as /something❌/something/foo/foo/foo✅/foo✅/somethingelseno match no match ✅ no match ✅ The over-simplified form is the tempting one and it is wrong for the bare prefix: ALB sends the original request to the target when no pattern matches, so
/somethingsilently reaches the backend un-rewritten while/something/fooworks. That asymmetry is easy to miss in testing. The correct form is exactly NGINX-equivalent on every input, and needs only plain alternation plus capturing groups — ALB's rewrite engine excludes lookarounds, backreferences, atomic groups, possessive quantifiers, subroutines, recursion and Unicode character classes, none of which this uses. (Transforms for listener rules) -
Multi-path Ingress needs separate
transforms.<svc>per backend service
TLS / Certificates
| Before (NGINX) | After (ALB) |
|---|---|
spec.tls[].secretName: my-secret | Remove spec.tls section entirely |
| K8s Secret with cert/key | alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:... |
| Multiple TLS secrets | Comma-separated ARNs, or omit certificate-arn to let the controller discover ACM certs from the Ingress tls hosts / rule host values |
nginx...ssl-redirect: "true" | alb.ingress.kubernetes.io/ssl-redirect: "443" |
| (none) | alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]' |
| (none) | alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06 |
Post-migration: Remove orphaned K8s TLS Secrets that are no longer referenced.
Edge termination ≠ done — address the ALB→pod hop (Zero-Trust / compliance). Moving TLS to ACM edge termination on the ALB and then sending plaintext HTTP to pods may violate organizational/Zero-Trust requirements, especially on EKS Auto Mode where in-cluster traffic is expected to be encrypted. When recommending edge termination, you MUST state how the ALB→backend hop is protected: e.g.
alb.ingress.kubernetes.io/backend-protocol: HTTPSto pods, aTargetGroupBindingwith HTTPS health/traffic, or a service mesh (mTLS). Never present "terminate at ALB, plaintext to pods" as the finished state without calling out the in-cluster encryption decision.
Proxy Timeouts
| Before (NGINX) | After (ALB) |
|---|---|
nginx...proxy-read-timeout: "120" | alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=120 |
nginx...proxy-send-timeout: "120" | (same — ALB uses single idle timeout) |
CORS
| Before (NGINX) | After (ALB) |
|---|---|
nginx...enable-cors: "true" | Remove — handle via AWS WAF or application-level |
nginx...cors-allow-origin | Remove — handle via AWS WAF or application-level |
nginx...cors-allow-methods | Remove — handle via AWS WAF or application-level |
nginx...cors-allow-headers | Remove — handle via AWS WAF or application-level |
Note: Add comment in manifest: # REMOVED: CORS — configure via AWS WAF rules or application middleware
Fidelity gap (not a simple annotation swap): CORS has no faithful ALB/WAF equivalent for the dynamic case. ALB can inject static CORS response headers, but via listener response-header attributes (
routing.http.response.*) — a listener-level setting that the load balancer adds to all responses on that listener, not a per-listener-rule action. That distinction matters on a shared/grouped ALB: you cannot vary the CORS values per host or path rule, so one origin policy applies to every route on the listener. It covers a fixedAccess-Control-Allow-Origin; but dynamic origin reflection (echoing the requestOriginagainst an allowlist) and preflightOPTIONSshort-circuiting have no native ALB/WAF equivalent and still need the application backend (or a Lambda/edge layer). AWS WAF cannot add CORS headers to normal (allowed) responses — header insertion is not a WAF capability on pass-through traffic; the only WAF-side headers you control are those on a custom response body for a Block action, which does not help a CORS allow-flow. On EKS Auto Mode the static-CORS workaround is unavailable entirely: AWS documents that you cannot setListenerAttributewith Auto Mode, and the response-header insertion above is a listener attribute — so on Auto Mode even the fixed-origin case falls back to the application (auto-configure-alb, checked 2026-09-13). Note too that the WAF-based workarounds for the sibling Tier-B features are themselves constrained on Auto Mode:waf-acl-id/web-acl-idare "Not supported" andwafv2-acl-nameis documented as "Upcoming soon". NGINXrate-limit/limit-rpsis per-second, per-path/per-client; AWS WAF rate-based rules are coarser — they default to per-IP aggregation (custom aggregation keys are available) over a configurable evaluation window (60–600s), cost extra, and use a completely different config model. Rate these by the Impact Indicator and the Feature-Gap classification inreport-generation.md— by the live traffic/security the lost fidelity affects, not by remediation effort (the app/WAF rework is an operator note, never a severity input). Because they lack a faithful equivalent they are not trivial swaps, but the band comes from the rubric, not from how much work the change is.
Authentication
| Before (NGINX) | After (ALB) |
|---|---|
nginx...auth-url + nginx...auth-signin | alb.ingress.kubernetes.io/auth-type: oidc |
| (external auth service) | alb.ingress.kubernetes.io/auth-idp-oidc: '{"issuer":"...","authorizationEndpoint":"...","tokenEndpoint":"...","userInfoEndpoint":"...","secretName":"..."}' |
⚠️ Not available on EKS Auto Mode's built-in controller. AWS documents
alb.ingress.kubernetes.io/auth-type: oidcas "Not supported" on Auto Mode ("OIDC Auth Type is currently not supported"), so this substitution requires a self-managed AWS Load Balancer Controller. If the estate is on Auto Mode, the Basic-Auth→OIDC path is not a drop-in: either install a self-managed LBC or keep the credential check in the app. Source: Create an IngressClass to configure an ALB (Ingress annotations table, checked 2026-09-13).
⚠️ Behavior change — NOT a like-for-like conversion. Basic Auth (
auth-type: basic, anAuthorization: Basicheader — commonly used by scripts, cron jobs and machine/API clients) → ALB OIDC/Cognito replaces header auth with an interactive browser login redirect to an Identity Provider. Every non-interactive caller (automation, CI, partner APIs) breaks immediately. This requires client re-architecture (e.g. OIDC client-credentials flow, app-level token auth, or mTLS) and stakeholder coordination — never presentauth-*→ OIDC as a simple annotation swap. Score it viareport-generation.md§1.3 as Tier-B — the faithful workaround is app-level credential validation — escalating to Tier-A (up to 5) only when non-interactive clients are present and the backend is a closed/unmodifiable third-party app, so the credential check cannot be moved into it. Always call out the affected client types.
Body Size
| Before (NGINX) | After (ALB) |
|---|---|
nginx...proxy-body-size: "50m" | Remove — no ALB annotation equivalent |
nginx...backend-protocol-version: GRPC | alb.ingress.kubernetes.io/backend-protocol-version: GRPC — ALB supports gRPC to the target group, but only over HTTPS/HTTP2 listeners; confirm the target group protocol version and the health-check settings, which differ from HTTP/1.1 |
nginx...permanent-redirect (+ …-code) | No annotation equivalent. Express the redirect as an ALB listener rule redirect action (or an ListenerRuleConfiguration on the Gateway path). It is not emitted by an Ingress annotation, so a converted manifest silently loses it — flag per route |
nginx...app-root | No equivalent — NGINX redirects / to the configured root. Reproduce as an explicit listener-rule redirect, or handle in the app. Silently lost on conversion |
nginx...custom-http-errors | No equivalent — depends on NGINX error_page interception. ALB has fixed-response actions but will not re-route upstream error codes to a backend error service; needs app-level handling. Silently lost on conversion |
Note: Add comment: # REMOVED: proxy-body-size — configure at application level
Access Control / Internal
| Before (NGINX) | After (ALB) |
|---|---|
nginx...whitelist-source-range: "10.0.0.0/8" | alb.ingress.kubernetes.io/scheme: internal |
| (IP restriction) | Use ALB security groups for IP-based access control |
Verify before switching internet-facing → internal (High-risk if skipped):
- Subnet readiness (read-only): an internal ALB needs private subnets tagged
kubernetes.io/role/internal-elbin ≥2 AZs. Checkaws ec2 describe-subnetsfor the cluster VPC; if absent, the internal ALB will fail to provision. Internet-facing ALBs likewise needkubernetes.io/role/elbtags.- External-consumer impact:
whitelist-source-rangeon an internet-facing endpoint still served external callers (partners, devices, off-cluster clients). Switching toscheme: internalmakes it reachable only from inside the VPC — every external consumer is cut off. Confirm no off-VPC client depends on it, or keep it internet-facing and restrict viaalb.ingress.kubernetes.io/inbound-cidrs/ security groups instead. Do not silently convert a public allowlisted endpoint to internal.
ALB Grouping (Cost Optimization)
To share a single ALB across multiple Ingress resources:
alb.ingress.kubernetes.io/group.name: shared-alb
alb.ingress.kubernetes.io/group.order: "10"
⚠️ On EKS Auto Mode,
group.nameas an Ingress annotation is "Not supported" — grouping must be declared on the IngressClass (viaIngressClassParams.spec.group.name) instead. A converted manifest carrying the annotation will silently not group on Auto Mode. Source: auto-configure-alb (checked 2026-09-13).
Blast radius — do NOT default to one shared ALB/Gateway across all teams. Consolidating
team-api,team-payments,team-webonto a single shared ALB (or a single Gateway) maximizes blast radius: one team's broken route, bad annotation, or traffic overload degrades everyone. Split by security boundary, e.g. apublicgroup/Gateway for general web and a separateprivate/payments group/Gateway for sensitive systems — accept the extra LB cost for isolation. Recommend grouping by trust/security boundary, not "one group to save money." This applies equally to Gateway API: prefer per-boundaryGateways over a single shared listener.
Migration Phases (ALB Path)
Phase 1: Prerequisites
- Install AWS Load Balancer Controller. There is no separately documented minimum for the plain ALB Ingress path — the controller has reconciled Ingress since its 2.x line, so use a currently supported release (pin the v3.5.0 line unless a constraint prevents it) rather than a floor. The floors that are documented and do bite are feature-specific: v2.14.1+ if any route needs a
transformsURI rewrite (see Rewrites above), v3.0.0+ for Gateway API. - Provision ACM certificates for all TLS hosts
- Ensure IAM roles/policies for LB Controller
Phase 2: Convert Manifests
- Apply annotation mapping (above) to each Ingress
- Use ATX for automated conversion (if available) — see
references/atx-guide.md - Validate with
kubectl apply --dry-run=client -f <file>
Phase 3: Deploy & Shift Traffic
⚠️ Do not flip
ingressClassNamein place on the live Ingress. The moment the class changes fromnginxtoalb, the NGINX controller stops serving that object and the ALB starts provisioning — there is no period where both paths are live, so there is nothing to weight and no rollback except editing the class back and waiting for NGINX to re-converge. An in-place flip is an all-or-nothing cutover, not a parallel run.
- Create the migrated Ingress as a new object with a new name (e.g.
web-alb) carryingingressClassName: alb, leaving the originalnginxIngress untouched and serving. Both now answer for the same host on different load balancers, which is what makes step 3 possible. - Wait for the ALB to provision and its target groups to report healthy; validate directly against the ALB DNS name (
Host:header) before any DNS change. - Use DNS weighted routing to shift traffic from the NGINX load balancer to the ALB, at a low TTL. Rollback = move the weight back; the NGINX path is still live and unmodified.
- Monitor error rates and latency at each weight step.
- Only once traffic is fully on the ALB, delete the original
nginxIngress (Phase 4).
If a parallel object is not possible (e.g. a GitOps repo that enforces one Ingress per host, or an admission policy blocking duplicate hosts), then say so plainly in the report and plan it as a low-TTL all-or-nothing cutover with a documented maintenance window — do not describe it as weighted or zero-downtime. The Gateway API path (Option 1) does not have this constraint, because the HTTPRoute/Gateway objects are new resources by construction and the Ingress keeps serving until deleted.
Phase 4: Cleanup
- Delete old NGINX Ingress resources
- Remove the NGINX Ingress Controller by uninstalling its release (
helm uninstall <release> -n <ns>) — not by deleting the Deployment. The chart also installs aningress-nginx-admissionValidatingWebhookConfiguration withfailurePolicy: Failand no selectors; left behind with no endpoints it makes the API server reject every Ingress create/update cluster-wide, including re-applying these migrated ALB Ingresses. Non-Helm installs: delete the admission webhook config and its Service explicitly. - Remove the controller's IngressClass and its
LoadBalancerService (this is what actually releases the old CLB/NLB and its security groups) - Remove orphaned TLS Secrets — check for a cert-manager
Certificateowner first; deleting a Secret that a liveCertificatestill owns triggers re-issuance (and can hit ACME rate limits) rather than cleaning anything up - Retire stale DNS: if external-dns managed the old hostnames, remove its ownership TXT records alongside the A/CNAME records, or it will keep trying to reconcile a load balancer that no longer exists
- Update IaC/GitOps references
Enumerate the survivors before declaring cleanup done. Uninstalling the release (or deleting the Deployment on a non-Helm install) leaves these behind, and each is a real orphan with cost or blast radius: the controller's own
LoadBalancerService (and therefore its CLB/NLB, security groups and any DNS records still pointing at it), theingress-nginx-admissionValidatingWebhookConfiguration and its admission Service, the controller's IngressClass, and any external-dns TXT/A records it owned. On a non-Helm install every one of these must be deleted explicitly. Verify with: noingress-nginxService of typeLoadBalancerremains,kubectl get validatingwebhookconfigurationsshows no ingress-nginx entry, and the old load balancer is gone fromelbv2 describe-load-balancers.
Checks to Execute
ALB.1 — Annotation Conversion Completeness
What to check:
- All
nginx.ingress.kubernetes.io/*annotations identified - Each has a mapped ALB equivalent or documented removal reason
Impact (per Impact Indicator):
- 🟡 1–2 (Low): Only mechanical changes (class swap on the same controller, scheme/target-type, simple path) — but see the cutover note below.
- 🟠 3–4 (Medium): URI rewrites →
transforms, TLS → ACM, timeouts; plus the new-ALB + DNS cutover every class switch incurs. - 🔴 5 (High), no workaround (Tier-A):
configuration-snippet/server-snippet(and the other no-equivalent features — Lua, mirror-to-arbitrary-backend, regex rewrite with capture groups, TLS passthrough, mTLS client-cert). These need application/code changes and raise the Re-architecture Gate. - Tier-B — score via the rubric, NOT automatically 🔴 5: CORS, rate-limit, IP-allowlist, Basic Auth → OIDC have a faithful workaround (app/backend layer, or WAF / Security Group), so they are rated per the
report-generation.md§1.3 Feature-Gap rubric — Impact 2–3 while that workaround can be applied, escalating to Tier-A (up to 5) only when it cannot (a closed/unmodifiable backend whose loss degrades a live business flow). For Basic Auth → OIDC the escalation additionally requires non-interactive clients — where every caller is a browser, ALB OIDC/Cognito is a faithful substitute.report-generation.md§1.3 is the source of truth for their score. - ⬜ Unknown: Cannot parse annotations
Cutover note (applies to EVERY nginx→alb conversion): switching
ingressClassName: nginx → albis a cross-controller change — the AWS LB Controller provisions a new ALB, and traffic does not move until DNS is cut over to it. This is a parallel-run + DNS cutover, never a zero-impact file edit. Therefore a bare class switch is at least Medium, even when the YAML diff is one line. (Changing the deprecatedkubernetes.io/ingress.classannotation tospec.ingressClassNameon the same class is the only genuinely Low case.)
ALB.2 — ACM Certificate Readiness
What to check:
- All TLS hosts have matching ACM certificates (or rely on certificate discovery by omitting
certificate-arn) - Certificates are in ISSUED state in the correct region
Impact (per Impact Indicator):
- 🟡 1–2 (Low): All certs available in ACM
- 🟠 3–4 (Medium): Some certs need provisioning
- 🔴 5 (High): Certs use private CA or non-standard issuance
- ⬜ Unknown: Cannot check ACM
ALB.3 — AWS LB Controller Readiness
What to check:
- AWS LB Controller installed and on a currently supported release (no documented minimum for the plain Ingress path; ≥ v2.14.1 if
transformsURI rewrites are used) - IAM role with correct policy attached
- IngressClass
albexists
Impact (per Impact Indicator):
- 🟡 1–2 (Low): Controller installed, correct version, IAM ready
- 🟠 3–4 (Medium): Controller present but needs upgrade
- 🔴 5 (High): Controller not installed
- ⬜ Unknown: Cannot determine