Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ShopSphere — Phase 20: HTTPS termination in front of the app.
#
# `tls internal` makes Caddy mint a self-signed cert from its own local CA — no ACME, because
# Whizlabs ephemeral public IPs can't pass Let's Encrypt's domain validation (and there's no domain).
#
# The site address is taken from $CADDY_SITE_ADDRESS so the SAME file works everywhere:
# - local testing: env unset -> defaults to `localhost`, so `curl -k https://localhost` works;
# - EC2 in the lab: user-data sets CADDY_SITE_ADDRESS to the instance's public IP (read from IMDS),
# so Caddy mints an internal cert with that IP in the SAN and `https://<ec2-public-ip>` works.
# A bare `:443` (no host) is intentionally NOT used — Caddy then has no name to issue a cert for and
# the TLS handshake fails. Caddy needs a concrete host/IP in the site address.
#
# Expect a browser cert warning — that's the untrusted self-signed CA, acceptable for this iteration.
# On graduation to own AWS, this proxy is replaced by ACM + ALB (a managed, trusted cert) — ADR-0020.
{
auto_https disable_redirects
# Browsers send NO SNI for a bare-IP URL, so Caddy can't match the IP-keyed site and would serve
# an empty fallback cert (→ ERR_SSL_PROTOCOL_ERROR). default_sni makes Caddy assume the site's
# address when SNI is absent, so it presents the IP cert (with the IP in its SAN) to browsers.
default_sni {$CADDY_SITE_ADDRESS:localhost}
}

{$CADDY_SITE_ADDRESS:localhost} {
tls internal
reverse_proxy app:8080
}
111 changes: 111 additions & 0 deletions compose.cloud.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ShopSphere — cloud deploy compose (Phase 12 + Phase 20).
#
# This runs ON the EC2 instance (written there by Terraform user-data), NOT on your laptop. It differs
# from the local docker-compose.yml in three deliberate ways:
# 1. No `postgres` — the app connects to the PRIVATE RDS (DB_HOST is the RDS endpoint, injected via .env).
# 2. No `localstack` — S3 image storage is dormant in the lab (S3_ENDPOINT blank → real-S3 resolution,
# never called by the core QA flow). Real-S3 wiring is deferred to own-AWS (ADR-0016).
# 3. `app` is pulled from Docker Hub (APP_IMAGE), not built — the EC2 has no source tree.
#
# All ${...} below are docker-compose env interpolation, resolved from /opt/shopsphere/.env on the EC2.
#
# Bring up (Phase 12): docker compose -f compose.cloud.yml up -d
# Bring up with HTTPS (Phase 20): docker compose -f compose.cloud.yml --profile caddy up -d

services:
# Fallback DB for sandboxes that deny RDS (use_rds=false → user-data adds `--profile localdb`).
# NOT the real Phase-12 design — the managed-private-RDS posture (ADR-0012) is unaffected: this
# service simply never starts when use_rds=true. App reaches it at DB_HOST=postgres (set by user-data).
postgres:
profiles: ["localdb"]
image: postgres:16
container_name: shopsphere-postgres
environment:
POSTGRES_DB: ${DB_NAME:-shopsphere}
POSTGRES_USER: ${DB_USER:-shopsphere}
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shopsphere} -d ${DB_NAME:-shopsphere}"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped

kafka:
image: confluentinc/cp-kafka:7.6.1
container_name: shopsphere-kafka
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
# Only the in-cluster PLAINTEXT listener is needed — nothing off the EC2 talks to Kafka directly.
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qg
# cp-kafka defaults to -Xmx1G, which alone exhausts a 1 GiB t2.micro and OOM-kills the stack.
# Cap it so app + Kafka coexist on the lab box (with swap as the safety net). See ADR-0012.
KAFKA_HEAP_OPTS: "-Xmx384m -Xms256m"
healthcheck:
test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:29092 >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 10s
retries: 15

app:
image: ${APP_IMAGE:-poojithvsc/shopsphere:latest}
container_name: shopsphere-app
depends_on:
kafka:
condition: service_healthy
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-shopsphere}
DB_USER: ${DB_USER:-shopsphere}
DB_PASSWORD: ${DB_PASSWORD}
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
JWT_SECRET: ${JWT_SECRET}
# S3 dormant: blank endpoint → SDK uses real-S3 resolution; the core QA flow never calls it.
S3_ENDPOINT: ""
AWS_REGION: ${AWS_REGION:-us-east-1}
# Cap the app heap so it shares the 1 GiB t2.micro with Kafka. JAVA_TOOL_OPTIONS is honoured by
# the JVM regardless of entrypoint. Lift/remove on a larger box.
JAVA_TOOL_OPTIONS: "-Xmx384m"
ports:
# Direct HTTP (Phase 12). Caddy (Phase 20) also fronts this on 443.
- "8080:8080"
restart: unless-stopped

caddy:
# Phase 20 — HTTPS termination with a self-signed cert (`tls internal`), reverse-proxying to the
# app. Only started under the `caddy` profile (Terraform user-data passes --profile caddy when
# enable_https=true). Whizlabs ephemeral IPs preclude Let's Encrypt, so self-signed is the choice.
profiles: ["caddy"]
image: caddy:2.8
container_name: shopsphere-caddy
depends_on:
app:
condition: service_started
environment:
# user-data sets PUBLIC_IP in .env (read from IMDS) so Caddy mints an internal cert with the
# instance's IP in the SAN. Falls back to localhost if somehow unset.
CADDY_SITE_ADDRESS: ${PUBLIC_IP:-localhost}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
ports:
- "443:443"
restart: unless-stopped

volumes:
caddy-data:
pgdata:
18 changes: 18 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,23 @@ services:
ports:
- "8081:8080"

caddy:
# Phase 20 — HTTPS reverse proxy in front of the app, self-signed via `tls internal`. Under the
# `full` profile so a local `docker compose --profile full up -d` lets you prove TLS works:
# `curl -k https://localhost/actuator/health` reaches the app. The same Caddyfile fronts the EC2
# in the cloud deploy (compose.cloud.yml). See ADR-0020.
profiles: ["full"]
image: caddy:2.8
container_name: shopsphere-caddy
depends_on:
app:
condition: service_started
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
ports:
- "443:443"

volumes:
shopsphere-pgdata:
caddy-data:
42 changes: 42 additions & 0 deletions docs/adr/0012-ec2-deploy-self-hosted-kafka-rds-private.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
status: accepted
date: 2026-06-06
cites: XP, PragProg, PoEAA
---

# 0012 — EC2 + self-hosted Kafka on one box; RDS goes private; Docker Hub over ECR

Phase 11 stood up a *public* RDS reachable from the developer laptop — a deliberate learning step. Phase 12 is the production-posture deploy: one `terraform apply` provisions an **EC2** that runs the app + Kafka via `compose.cloud.yml` (image pulled from Docker Hub) and a **private RDS** whose only ingress is the EC2's security group. This is the first time ShopSphere runs as it would in front of a user, not on a laptop.

## Self-hosted Kafka in compose on the box, not MSK / Confluent Cloud

The EC2 runs Kafka as a compose service beside the app — the *same* `confluentinc/cp-kafka` image and KRaft config as local dev. **XP YAGNI + the Whizlabs-ephemeral constraint:** MSK and Confluent Cloud are managed, durable, multi-AZ brokers — everything an ephemeral 4-hour lab is not. Provisioning MSK would add a VPC/subnet/permission story, minutes of standup, and cost, to back a broker that's deleted at lab end. **PragProg dev/prod parity:** running the identical broker image locally and on the EC2 means the deploy exercises the exact Kafka the tests and dev loop use — no "works locally, breaks on MSK" surprises. The honest cost is recorded below: single-node, no HA, no durability beyond the box.

## RDS flips to private — the network is the security control

`publicly_accessible = false`, and the RDS security group's only ingress is the EC2's security group (not a laptop /32). The acceptance check is a *negative*: `psql -h <rds-endpoint>` from the laptop must **time out**. **PoEAA / PragProg — push the control to the boundary:** the database isn't protected by application logic or a password alone; it's unreachable off the VPC. The app reaches it because the app runs inside the trust boundary (on the EC2 whose SG is allow-listed). **XP incremental design:** Phase 11 used a public RDS to *learn* the RDS + Flyway path with the laptop as client; Phase 12 removes that affordance now that the EC2 is the client. The two postures are different scenarios, so they live as two self-contained Terraform configs (`terraform/rds/` historical, `terraform/ec2/` the deploy) rather than a migrated state — correct because the lab is throwaway and there's no shared state to preserve.

## Docker Hub, not ECR

The image is built locally and pushed to a public Docker Hub repo (`poojithvsc/shopsphere:latest`), pulled by EC2 user-data. **XP simplicity + the lab constraint:** ECR adds an extra Whizlabs permission scope and a registry that dies with the session; a public Docker Hub repo is a one-line `docker push` and a zero-auth pull. Phase 19 (deferred) would automate this push from CI. The push is manual in this phase and documented in the lab runbook.

## Throwaway-lab posture, made explicit

Default VPC (no bespoke network), t3.micro, no backups, no final snapshot, `apply_immediately`. **XP YAGNI:** a 4-hour box does not warrant private subnets + NAT or a backup plan. This is correct *only* because the lab is ephemeral and holds no real data — the same honesty as ADR-0011. The IAM instance profile is minimal (no SSM yet); ADR-0013 already documents what own-AWS would add.

## Fallback: Postgres as a container when RDS is unavailable (`use_rds=false`)

Added live during the 2026-06-06 lab: the Whizlabs **Cloud Sandbox denies RDS entirely** (even `rds:Describe`), while the only RDS-capable lab (the guided "EC2+RDS Terraform" one) is 60-min and attempt-capped. So the module gained a `use_rds` toggle. When `false`, all RDS resources are skipped (`count = 0`) and a `postgres:16` container runs on the EC2 under a `localdb` compose profile; the app reaches it at `DB_HOST=postgres`.

This is **not** the target architecture and it does **not** demonstrate this ADR's headline lesson — the *managed, private DB behind a network boundary*. A co-located container has no separate network to lock down; the "laptop psql must time out" acceptance check is meaningless against it. That check stays **deferred** to RDS-capable AWS (the guided lab after its attempt reset, or own-AWS), exactly as #58 records.

Why keep it in the codebase rather than as a throwaway branch (decided with the books): it is a genuine **two-value seam**, not a dead toggle — `false` ran live, `true` is `terraform validate`-clean and is the own-AWS path. That mirrors the project's existing abstraction-behind-a-seam stance (cf. ADR-0015's payment stub). **XP YAGNI** is noted honestly: once own-AWS is the only target the container path is dead weight and may be removed; until then it earned its place by being the only way the deploy ran at all.

## Consequences

`terraform apply` brings up the whole deploy; `terraform destroy` removes it. `mvn verify` is unaffected (this is infrastructure, no app code). Three honest limits, recorded so they don't surprise later:

- **t3.micro is tight** — app and Kafka are two JVMs in 1 GiB. `instance_class` is a variable; bump to t3.small if the app OOMs on boot.
- **S3 image storage is dormant in the lab** — `compose.cloud.yml` sets `S3_ENDPOINT` blank, so the SDK resolves real S3 but the core QA flow never calls it (no LocalStack on the box). Real-S3 wiring stays deferred to own-AWS (ADR-0016).
- **Kafka is single-node, non-durable** — fine for the walkthrough, not a statement about production topology.
- The acceptance criteria that *prove* this (app reachable, RDS-private timeout, QA over the EC2 endpoint, `terraform destroy`) are **manual lab steps** — they need a live Whizlabs session, like the other cloud phases. On graduation to own AWS the same module runs with a different `terraform.tfvars` and would add private subnets + NAT, ACM/ALB (ADR-0020), and SSM (ADR-0013). Phase 20 adds HTTPS in front of this same EC2.
29 changes: 29 additions & 0 deletions docs/adr/0020-https-via-caddy-tls-internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: accepted
date: 2026-06-06
cites: XP, PragProg, APoSD
---

# 0020 — HTTPS via Caddy with a self-signed `tls internal` cert

Phase 20 puts TLS in front of the Phase-12 EC2: a Caddy container reverse-proxies `:443` to the app's `:8080`, terminating HTTPS. The whole config is a ~15-line `Caddyfile` and one compose service. Browsers reach `https://<ec2-public-ip>` and get the app behind TLS, after dismissing a self-signed-cert warning that is expected and acceptable for this iteration.

## Self-signed, because Let's Encrypt is impossible here — not because it's easier

ACME / Let's Encrypt issues *publicly trusted* certs, but only after proving control of a **domain**. A Whizlabs lab has an **ephemeral public IP and no domain**, so domain validation can't happen. `tls internal` makes Caddy mint a cert from its own local CA — untrusted by browsers (hence the warning), but real TLS on the wire. **PragProg — be honest about the trade:** this is the *correct* choice given the constraint, not a shortcut; the limitation (browser warning, no public trust) is named, not hidden. The cert carries the instance's IP in its SAN (browsers send no SNI for bare-IP URLs), which is why the site address is the public IP, injected at deploy time.

## Caddy over nginx — the cert lifecycle is hidden behind two words

`tls internal` is the entire TLS story: Caddy generates the CA, issues the leaf, renews it, and serves it. The nginx equivalent is a `openssl req` to generate a self-signed cert, a volume to mount it, `ssl_certificate`/`ssl_certificate_key` directives, and a renewal you own. **APoSD — deep module:** Caddy presents a two-word interface over the whole certificate lifecycle; nginx exposes the mechanism and makes the operator the lifecycle manager. **XP YAGNI:** for a reverse proxy that does TLS + one `reverse_proxy` line, the simpler tool wins.

## Same Caddyfile local and cloud — proven before the lab

The site address comes from `{$CADDY_SITE_ADDRESS:localhost}`, so the committed `Caddyfile` is byte-identical in both places: unset locally → `localhost` (so `curl -k https://localhost` works on a laptop), and set to the instance IP on the EC2. **PragProg tracer bullet:** TLS termination was proven end-to-end locally (`https://localhost/actuator/health` returns the app's JSON through Caddy; `/api/v1/products` returns 401 — routing + auth intact) **before** spending a minute of lab time. The lab run is then confirmation, not discovery. A bare `:443` site address was tried first and rejected: with no host/IP, Caddy has no name to issue a cert for and the handshake fails — the site address must be concrete.

## Consequences

HTTPS fronts the app with a self-signed cert; plain `http://<ip>:8080` stays open in this iteration (the SG allows both) and is documented as such — closing it would force HTTPS but is unnecessary for the lab. `mvn verify` is unaffected (infra only). Honest limits:

- **Browser warning is inherent** to self-signed; this is not production-grade trust.
- **The on-EC2 acceptance** (`https://<ec2-public-ip>` in a browser, QA walkthrough over HTTPS) is a **manual lab step**, batched with Phase 12 in one Whizlabs session — the two phases share the same ephemeral EC2.
- **On graduation to own AWS, this proxy is replaced by ACM + ALB** — a managed, publicly-trusted cert with auto-renewal, terminating at the load balancer. That's the real-world end state; self-signed Caddy is the lab-appropriate stand-in. Phase 20 is the last numbered phase; nothing depends on it (it's HTTPS polish), so it is skippable in-lab if Phase 12 consumes the time box.
Loading
Loading