# Elastic Beanstalk Docker 2024

> Deploy a Dockerized backend to AWS Elastic Beanstalk with ALB, ACM, HTTPS, and SES SMTP. Covers the 5 gotchas no AWS doc explains clearly.

- Skill: `mobigaurav/elastic-beanstalk-docker-2024` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add mobigaurav/elastic-beanstalk-docker-2024`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mobigaurav/elastic-beanstalk-docker-2024/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: mobigaurav (https://skillmd.com/u/mobigaurav)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/mobigaurav/elastic-beanstalk-docker-2024

---


# 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)

1. **`.ebextensions` is CloudFormation underneath.** Anything you declare there
   is owned by CFN. Anything you create imperatively (via AWS CLI / Console)
   that also appears in `.ebextensions` will cause `AlreadyExists` errors and
   stack rollback on the next deploy.

2. **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`.

3. **Frontend build-time env vars must be present at `docker build` time,**
   not container runtime. Vite / CRA / Next inline them into the bundle.

4. **SES SMTP `Username` is an IAM access key, not an email address.**
   Treat From-address as a separate config knob.

5. **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`:

```yaml
# .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](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
# 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
```

```yaml
# .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](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:

```bash
# 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](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:

```python
# 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"
```

```python
# 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](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:

```bash
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:
1. Exact email types (verification / password reset / transactional notifications)
2. Recipient source (self-registered / opted-in)
3. Expected volume + ramp
4. Bounce/complaint handling plan
5. **No marketing or promotional content** (if true — this is the magic phrase)

See [reference/ses-production-request-template.md](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-ingress`
  on EB-managed resources. Use `.ebextensions` + `update-environment`.
- Don't set `Application Healthcheck URL` and expect it to apply to ALB.
- Don't put `VITE_API_BASE_URL` only 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](reference/ebextensions-template.config) — full working `.ebextensions` for ALB + HTTPS + health check
- [reference/cd-workflow-example.yml](reference/cd-workflow-example.yml) — GitHub Actions CD with conditional build-args
- [reference/dockerfile-vite-buildargs-pattern](reference/dockerfile-vite-buildargs-pattern) — multi-stage Dockerfile pattern
- [reference/ses-production-request-template.md](reference/ses-production-request-template.md) — approved-on-first-try description template
- [examples/full-walkthrough.md](examples/full-walkthrough.md) — narrative of a real go-live where all 5 gotchas appeared in sequence

