Elastic Beanstalk + Docker production deploy (2024 stack)
You are helping a developer deploy a Dockerized backend (FastAPI / Express / Spring / etc.) to AWS Elastic Beanstalk with an Application Load Balancer, ACM TLS cert, and AWS SES SMTP for transactional email.
This skill front-loads the five specific failure modes that consistently burn 1–3 days of debugging time per developer because the official AWS docs are silent, ambiguous, or accurate-but-buried.
Core principles (apply throughout)
.ebextensionsis CloudFormation underneath. Anything you declare there is owned by CFN. Anything you create imperatively (via AWS CLI / Console) that also appears in.ebextensionswill causeAlreadyExistserrors and stack rollback on the next deploy.EB health-check options are split across two namespaces. The legacy "Application Healthcheck URL" only applies to Classic ELB. For ALB you MUST use
aws:elasticbeanstalk:environment:process:default.Frontend build-time env vars must be present at
docker buildtime, not container runtime. Vite / CRA / Next inline them into the bundle.SES SMTP
Usernameis an IAM access key, not an email address. Treat From-address as a separate config knob.ALB security-group ingress for port 443 is NOT created automatically when you add a 443 listener. CFN owns it only if you declare it in
.ebextensions.
The 5 gotchas (in dependency order)
Gotcha 1 — ALB target-group health check returns 404, env stuck "Severe"
Symptom: Fresh EB env shows Severe health. /healthz returns 200 when you
curl directly. ELB describes targets as unhealthy. EB logs show the legacy
"Application Healthcheck URL: /healthz" setting is applied.
Why it fails: That legacy field only applies to Classic ELB. The ALB
target group's health-check path defaults to /, which most apps return
404 for. The TG health check rejects 404 → targets unhealthy → env Severe.
Fix: Set the modern namespace either via .ebextensions (preferred) or
aws elasticbeanstalk update-environment:
# .ebextensions/01_environment.config
option_settings:
aws:elasticbeanstalk:environment:process:default:
HealthCheckPath: /healthz
Port: 80
Protocol: HTTP
MatcherHTTPCode: '200'
See diagnostics/alb-target-unhealthy.md for the full diagnostic flow.
Gotcha 2 — Vite bundle hits the wrong API URL
Symptom: Production web app makes API requests to https://yourdomain.com/api/v1/...
(same origin as the static site) instead of https://api.yourdomain.com/api/v1/....
Result is 405 Method Not Allowed (nginx serves static, doesn't accept POST).
Why it fails: Vite inlines VITE_* env vars at build time, not runtime.
If your Dockerfile doesn't pass VITE_API_BASE_URL to the pnpm build
stage, the bundle bakes whatever the fallback default was (typically a
relative path).
Fix: Three coordinated changes:
# Dockerfile (builder stage)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN pnpm build
# .github/workflows/cd.yml (build-and-push job)
- uses: docker/build-push-action@v6
with:
context: ./web
build-args: |
${{ matrix.service == 'web' && format('VITE_API_BASE_URL={0}/api/v1', secrets.PROD_BACKEND_URL) || '' }}
See reference/dockerfile-vite-buildargs-pattern for the complete pattern.
Gotcha 3 — aws elbv2 create-listener then deploy → CFN rollback
Symptom: You created the HTTPS:443 listener imperatively via AWS CLI
because it was faster than writing .ebextensions. It worked. Next deploy
fails with Resource of type 'AWS::ElasticLoadBalancingV2::Listener' … already exists. Stack rolls back. Worse: the rollback strips the SG 443
ingress you also created imperatively, so HTTPS goes down.
Why it fails: EB drives deploys through CloudFormation. When you add a
.ebextensions declaration for a resource that already exists outside CFN's
control, CFN tries to CREATE it (not import it). The CREATE collides.
Fix: Hand the listener to CFN cleanly:
# 1) Delete the imperative resource
aws elbv2 delete-listener --listener-arn $LISTENER_ARN
# 2) Push .ebextensions with the declaration
git add .ebextensions/01_environment.config && git commit && git push
# 3) Trigger update-environment with the same option settings so CFN
# creates the listener AND the SG ingress in one stack update.
aws elasticbeanstalk update-environment --environment-name ... \
--option-settings file://https-listener-options.json
See diagnostics/cfn-listener-already-exists.md for the full incident playbook including SG ingress recovery.
Gotcha 4 — SES "Email address is not verified" with verified domain
Symptom: You verified yourdomain.com in SES. DKIM is SUCCESS. You
configured SMTP with SMTP_USERNAME=AKIA… (the IAM access key) and
SES rejects sends with Email address is not verified (yourdomain.com).
Why it fails: Most app frameworks default the From header to the SMTP
username. For SES, the username is an IAM access key like AKIAQ7X…,
not an email address. SES tries to verify "AKIAQ7X..." as the sender and
rejects it.
Fix: Separate the SMTP credentials from the From identity:
# config.py
class Settings(BaseSettings):
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None # AKIA... access key
smtp_password: str | None = None # derived SMTP password
smtp_from_address: str | None = None # noreply@yourdomain.com (verified)
smtp_from_name: str | None = None # "Your Brand"
# email_adapter.py
addr = settings.smtp_from_address or "noreply@yourdomain.com"
name = settings.smtp_from_name or "Your Brand"
msg["From"] = f"{name} <{addr}>"
Then set 6 env vars on the deploy environment:
EMAIL_PROVIDER=smtp
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
SMTP_PORT=587
SMTP_USERNAME=AKIA… # from `aws iam create-access-key`
SMTP_PASSWORD=… # derived via SES SMTP password algorithm
SMTP_FROM_ADDRESS=noreply@yourdomain.com
SMTP_FROM_NAME=Your Brand
See diagnostics/ses-smtp-from-address.md for the SMTP password derivation algorithm and IAM policy.
Gotcha 5 — SES sandbox blocks real users
Symptom: Sending to your own verified email works. Sending to a
newly-registered user fails silently or hits MessageRejected.
Why it fails: New SES accounts are in sandbox mode (200/day, 1/sec, only verified recipients). You need to file a production-access request.
Fix: File the request as soon as you have a verified domain — it takes ~24h for AWS to approve:
aws sesv2 put-account-details \
--production-access-enabled \
--mail-type TRANSACTIONAL \
--website-url https://yourdomain.com \
--use-case-description "Transactional emails only: ..." \
--contact-language EN
The description matters. Reviewers reject vague requests. Include:
- Exact email types (verification / password reset / transactional notifications)
- Recipient source (self-registered / opted-in)
- Expected volume + ramp
- Bounce/complaint handling plan
- No marketing or promotional content (if true — this is the magic phrase)
See reference/ses-production-request-template.md.
Diagnostic decision tree
Use this when the user reports a symptom:
EB env "Severe" / target unhealthy?
→ Gotcha 1 (ALB health check namespace)
Production deploy fails with "AlreadyExists" or CFN rollback?
→ Gotcha 3 (imperative/declarative conflict)
Frontend reports 405 / requests hit wrong host?
→ Gotcha 2 (Vite build-args)
Email sends succeed in code but never arrive / SES rejects?
→ If "not verified": Gotcha 4 (From-address)
→ If only own address works: Gotcha 5 (sandbox)
Things NOT to do
- Don't run
aws elbv2 create-listener/authorize-security-group-ingresson EB-managed resources. Use.ebextensions+update-environment. - Don't set
Application Healthcheck URLand expect it to apply to ALB. - Don't put
VITE_API_BASE_URLonly in CD env vars — they must be Docker build args. - Don't use the SES IAM access key as your From address.
- Don't request SES production access with vague descriptions ("we send emails").
- Don't manually edit ALB resources in the AWS Console for EB envs — the next deploy will revert your changes or fail.
Reference materials in this skill
- reference/ebextensions-template.config — full working
.ebextensionsfor ALB + HTTPS + health check - reference/cd-workflow-example.yml — GitHub Actions CD with conditional build-args
- reference/dockerfile-vite-buildargs-pattern — multi-stage Dockerfile pattern
- reference/ses-production-request-template.md — approved-on-first-try description template
- examples/full-walkthrough.md — narrative of a real go-live where all 5 gotchas appeared in sequence