00

Why this guide exists

Part 3 shipped KitePDF on a single EC2 host behind nginx on port 80, with browsers talking to a raw public IP. That shape proved the product on real AWS. This part covers what we changed next: a real domain, HTTPS, and Caddy as the production edge—plus the URL, CORS, and auth alignment work that a domain forces into the open.

Read it as a learning note, not marketing: what we tried, why Caddy won for ACME, how Terraform still owns AWS shape (we do not click through the console to stand up buckets or security groups), and the exact order of operations when DNS, GitHub secrets, terraform apply, and workflow_dispatch all have to line up.

01

What changed since Part 3

The application plane did not grow. We still run one Amazon Linux 2023 instance, Docker Compose, Neon for both Postgres databases, S3 for bytes, SQS for jobs, and SSM for secrets. The changes sit at the edge and in every place that embeds a public origin.

LayerPart 3 (bootstrap)Today (production)
Public URLhttp://EC2_PUBLIC_IPhttps://kitepdf.pro (www redirects to apex)
Edge proxynginx on :80 (localstack.conf)Caddy on :80/:443 with automatic Let's Encrypt
TLSNoneACME via Caddy; certs in Docker volume caddy_data
Image tagsprod (amd64 assumed):prod-amd64 on t3.micro; CI builds linux/amd64 only
S3 CORShttp://public-ip from TerraformIP plus https://kitepdf.pro and https://www.kitepdf.pro
Deploy workflowDeploy AWS (nginx)Deploy AWS (Caddy) with caddy_tls toggle; nginx path retained
AWS resourcesTerraform in infra/aws (IAM user credentials)Unchanged—still no console-driven provisioning for S3, SQS, SSM, SG, EC2
Same Compose apps; the public contract moved from IP+HTTP to domain+HTTPSDiagram

Same Compose apps; the public contract moved from IP+HTTP to domain+HTTPS

02

Terraform still owns AWS

HTTPS and Caddy sit on top of the same Terraform stack Part 3 documents under infra/aws. Your IAM user runs terraform init, plan, and apply locally (or in CI later)—that creates the S3 bucket with CORS wired to the EC2 public IP, SQS queues, SSM parameters under ssm_base_path, CloudWatch log group, security group (SSH + HTTP/HTTPS), and the EC2 instance with an instance profile. We do not recreate those resources in the AWS console; the console is for reading state when debugging, not for provisioning.

ResourceWhy TerraformPart 4 touchpoint
S3 + CORSBrowser uploads use presigned URLs; origins must match the public siteAdd cors_extra_origins in terraform.tfvars when the domain goes live; re-apply
EC2 + security groupSingle Compose host; ports 80/443 for Caddy ACME and HTTPSec2_public_ip output → EC2_HOST secret and DNS A records
SSM Parameter StoreSecrets never committed; containers read at startupSame path as Part 3; URL promotion is mostly GitHub secrets + frontend bake
SQS + IAM roleWorkers scale via queues, not console clicksUnchanged when switching nginx → Caddy
  • After every apply, capture terraform output ec2_public_ip—it feeds EC2_HOST, bootstrap CORS, and Hostinger A records until you add an Elastic IP.
  • cors_extra_origins is HCL in tfvars, not a bucket policy edit in the UI.
  • When the instance stops and starts, public IP can change: terraform apply refreshes S3 CORS; you must update DNS and any IP-based env leftovers.
Provision (or refresh) AWS before touching DNS or Caddy TLSbash
cd infra/aws
cp terraform.tfvars.example terraform.tfvars # fill Neon, secrets, ec2_key_name, ssh_ingress_cidr
terraform init && terraform plan && terraform apply
terraform output ec2_public_ip
terraform output cors_allowed_origins
terraform output ssm_parameter_path
03

Why Caddy over nginx for TLS

We evaluated keeping nginx and adding TLS the conventional way: obtain certificates, drop fullchain.pem and privkey.pem under nginx/certs/, switch the mount from localstack.conf to aws.conf, open 443 on the security group, and schedule renewals. That path works—nginx/aws.conf already names kitepdf.pro—but it optimizes for operators who want explicit control of every PEM file.

Caddy optimizes for the opposite: automatic HTTPS as a default. Point DNS at the instance, set SITE_HOST and ACME_EMAIL, publish 80 and 443, and Caddy obtains and renews certificates. For a single-host product where the edge's job is reverse proxy plus TLS—not complex Lua or multi-tenant vhosts—that is the right trade.

Concernnginx + manual certsCaddy automatic HTTPS
First HTTPS bring-upIssue certs, copy PEMs, wire aws.conf, reloadDNS + SITE_HOST + ACME_EMAIL + compose up
Renewalcron, certbot, or external automationBuilt-in; persists in caddy_data volume
Config surface for our routesFamiliar; we already had localstack.confCaddyfile.prod mirrors the same path rules
HTTP/3Extra modules / config443/udp published in the Caddy compose file
Team familiarityHigher for many ops backgroundsLower initially; outweighed by ACME simplicity
  • Automatic Let's Encrypt with a global ACME email in the Caddyfile.
  • www → apex permanent redirect with TLS on both names.
  • HSTS on the apex site block.
  • Same long timeouts and streaming-friendly flush behavior for /core/v1/* as nginx.
  • 25MB request body limit at the edge for upload-sized tools.
04

Edge routing: same paths, new process

Routing did not change philosophy. The browser still hits one public entrypoint. Caddy terminates TLS, then splits traffic the same way nginx/localstack.conf and caddy/Caddyfile.local did: product UI on /, PDF API on /core/v1/*, probes and docs on explicit /core/* paths. Workers never see browser cookies; they stay on the internal Compose network and authenticate to the gateway with INTERNAL_SECRET from SSM.

PathUpstreamPurpose
/frontend:3000Next.js app and Better Auth routes
/core/v1/*api-gateway:8080PDF tools API (long timeouts, streaming)
/core/healthapi-gateway:8080Load balancer / operator health check
/health301 → /core/healthLegacy probe URL kept for compatibility
/core/docsapi-gateway:8080Scalar OpenAPI UI when ENABLE_API_DOCS=true
/core/openapi/openapi.yamlapi-gateway:8080Spec file Scalar fetches
www hostname301 → apexSingle cookie origin on kitepdf.pro
Caddy terminates TLS; path split matches the nginx/localstack eraDiagram

Caddy terminates TLS; path split matches the nginx/localstack era

Caddyfile.prod mirrors caddy/Caddyfile.local on purpose: parity between IP bootstrap and HTTPS production. SITE_HOST and ACME_EMAIL come from Compose environment substitution. The apex block owns HSTS, explicit handles for health and docs, the API reverse_proxy with hour-long read/write timeouts and flush_interval -1 for streaming responses, and a default handle to frontend:3000.

caddy/Caddyfile.prod (shape)bash
# Shape of caddy/Caddyfile.prod (illustrative)
{
email {$ACME_EMAIL}
}
www.{$SITE_HOST} {
redir https://{$SITE_HOST}{uri} permanent
}
{$SITE_HOST} {
request_body { max_size 25MB }
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
redir /health /core/health 301
redir /core/docs/ /core/docs 301
# @api_long /core/v1/* → api-gateway (long timeouts)
# handle /core/health, /core/docs, /core/openapi/openapi.yaml → api-gateway
# handle { ... } → frontend:3000
}
05

Domain as a single change set

A domain is not a DNS A record alone. Auth cookies, JWT issuer and audience, NEXT_PUBLIC_* bake-time values, Better Auth base URL, and S3 CORS must all agree on the same origin. Treat promotion as one change set—not a sequence of half-updated environments.

SurfaceMust becomeNotes
DNSapex + www → EC2 public IPNo Elastic IP yet; stop/start can change the IP
NEXT_PUBLIC_BETTER_AUTH_URL / NEXT_PUBLIC_API_URLhttps://kitepdf.pro and https://kitepdf.pro/core/v1Rebuild and push frontend; baked into the image
BETTER_AUTH_URLhttps://kitepdf.proNo trailing slash (same rule as Part 3)
AUTH_ISSUER / AUTH_AUDIENCEhttps://kitepdf.pro/Trailing slash as configured in the gateway
AUTH_JWKS_URLhttp://frontend:3000/api/auth/jwksStill Docker DNS—never the public hostname
Terraform cors_extra_originshttps://kitepdf.pro and https://www.kitepdf.proPlus auto http://EC2_IP for bootstrap windows
SITE_HOST / ACME_EMAILkitepdf.pro + operator emailRequired for caddy_tls=true deploys
Public origin alignment (illustrative)bash
# Browser / cookies
BETTER_AUTH_URL=https://kitepdf.pro
NEXT_PUBLIC_BETTER_AUTH_URL=https://kitepdf.pro
NEXT_PUBLIC_API_URL=https://kitepdf.pro/core/v1
# API gateway JWT checks
AUTH_ISSUER=https://kitepdf.pro/
AUTH_AUDIENCE=https://kitepdf.pro/
# Inside Compose — unchanged from Part 3
AUTH_JWKS_URL=http://frontend:3000/api/auth/jwks
# Caddy ACME
SITE_HOST=kitepdf.pro
ACME_EMAIL=ops@example.com
06

Compose files and the caddy_data volume

The repo keeps a small matrix so you can bootstrap by IP and promote to TLS without inventing a new stack. Application services are identical across Caddy compose files; only the edge service and published ports differ.

Compose fileEdgeWhen to use
docker-compose.nginx.prod.aws.ymlnginx :80Legacy HTTP path; Deploy AWS workflow
docker-compose.caddy.prod.aws.http.ymlCaddy HTTP :80Caddy by IP before DNS/ACME (caddy_tls=false)
docker-compose.caddy.prod.aws.ymlCaddy :80/:443 + 443/udpProduction HTTPS (caddy_tls=true)
  • TLS compose publishes 80, 443, and 443/udp; security group already allows 80 and 443.
  • caddy_data is a named Docker volume—certificate state lives there.
  • Deploys use compose up -d. Never compose down -v on production; wiping volumes deletes issued certs and forces re-issuance drama.
  • PDFMASTER_IMAGE_TAG=prod-amd64 keeps EC2 and CI on the same architecture contract.
Host smoke after TLS cutoverbash
# HTTPS stack on the host (after DNS points at the instance)
docker compose --env-file .env.prod.aws \
-f docker-compose.caddy.prod.aws.yml pull
docker compose --env-file .env.prod.aws \
-f docker-compose.caddy.prod.aws.yml up -d
curl -sfI https://kitepdf.pro/core/health
curl -sfI https://kitepdf.pro/core/docs | head -n 5
curl -sfI https://www.kitepdf.pro/ | head -n 5
# expect redirect to apex
07

Delivery: Deploy AWS (Caddy)

The blessed path for the domain stack is the Deploy AWS (Caddy) GitHub Actions workflow. It is the last step in a chain—not a standalone button. Terraform must have created the host and SSM path; EC2_HOST and SSH secrets must match terraform output ec2_public_ip; NEXT_PUBLIC_* and auth URLs must already reflect the origin you want baked into the frontend image. The workflow builds four :prod-amd64 images, pushes to Docker Hub, rsyncs compose + Caddyfiles + generated .env.prod.aws to the instance, stops any competing edge stack, and runs compose pull && up -d.

Terraform and secrets first; workflow_dispatch lastDiagram

Terraform and secrets first; workflow_dispatch last

  • Copy .env.github.aws.example → .env.github.aws; set EC2_HOST from terraform output ec2_public_ip; run gh secret set -f .env.github.aws and gh secret set EC2_SSH_KEY < key.pem separately.
  • workflow_dispatch input caddy_tls=true requires SITE_HOST and ACME_EMAIL secrets—set them before the TLS deploy, not after DNS fails ACME.
  • caddy_tls=false uses docker-compose.caddy.prod.aws.http.yml for IP-only bring-up (still needs correct NEXT_PUBLIC_* for that origin).
  • Optional DEPLOY_COMPOSE_FILE secret overrides compose selection for advanced cases.
  • Concurrency group deploy-aws-caddy prevents overlapping SSH deploys.
  • Frontend build-args pull NEXT_PUBLIC_* from secrets—changing the domain without re-running the workflow leaves the old origin in the image.
.github/workflows/deploy-aws-caddy.yml (shape)yaml
name: Deploy AWS (Caddy)
on:
workflow_dispatch:
inputs:
caddy_tls:
description: Use automatic HTTPS (requires SITE_HOST + ACME_EMAIL)
type: boolean
default: false
# build → push :prod-amd64 → rsync bundle → compose pull/up
08

Runbook: terraform apply → DNS → workflow

Promotion is sequential. Skipping a step or running them out of order produces the confusing failures in the lessons table—login on HTTPS with JWT issuer still on http://IP, ACME failures with DNS still on the old host, or CORS allowing the domain while the frontend image still bakes the EC2 IP. Follow the order below even if you already ran Part 3 once; refresh Terraform outputs before you trust old secrets.

One ordered path from infra to https://kitepdf.proDiagram

One ordered path from infra to https://kitepdf.pro

  1. STEP01

    Apply Terraform (create or refresh AWS)

    From infra/aws with your IAM user credentials—not the EC2 instance role. This is the source of truth for bucket, queues, SSM, security group, and EC2. Save ec2_public_ip; you will paste it into GitHub secrets and DNS.

    Apply Terraform (create or refresh AWS)yaml
    cd infra/aws && terraform apply
    terraform output ec2_public_ip
  2. STEP02

    Load GitHub Actions secrets

    Copy .env.github.aws.example to .env.github.aws. Set EC2_HOST to the Terraform output IP (later unchanged if you only add DNS). Fill Neon URLs, REDIS_PASSWORD matching tfvars, SSM path, auth URLs for your current phase (IP or domain), SITE_HOST and ACME_EMAIL before the TLS deploy. Push secrets with gh secret set -f .env.github.aws; load the PEM with gh secret set EC2_SSH_KEY.

    Load GitHub Actions secretsyaml
    cp .env.github.aws.example .env.github.aws
    # edit EC2_HOST, NEXT_PUBLIC_*, AUTH_*, BETTER_AUTH_URL, SITE_HOST, ACME_EMAIL, ...
    gh secret set -f .env.github.aws
    gh secret set EC2_SSH_KEY < ~/.ssh/your-keypair.pem
  3. STEP03

    First deploy — HTTP Caddy by IP

    GitHub → Actions → Deploy AWS (Caddy) → Run workflow with caddy_tls=false. Confirms SSH, compose, SSM, Neon, and S3 presigns before you attach a domain. Hit /core/health and optionally /core/docs if ENABLE_API_DOCS is on.

    First deploy — HTTP Caddy by IPyaml
    # After workflow succeeds
    curl -sf "http://$(terraform -chdir=infra/aws output -raw ec2_public_ip)/core/health"
  4. STEP04

    Align origins in Terraform and secrets

    Before HTTPS, set cors_extra_origins to https://kitepdf.pro and https://www.kitepdf.pro in terraform.tfvars. Update GitHub secrets (and thus generated .env.prod.aws) so BETTER_AUTH_URL, AUTH_ISSUER, AUTH_AUDIENCE, and NEXT_PUBLIC_* all use https://kitepdf.pro. Apply Terraform so S3 CORS matches.

    Align origins in Terraform and secretsyaml
    cors_extra_origins = [
    "https://kitepdf.pro",
    "https://www.kitepdf.pro",
    ]
    cd infra/aws && terraform apply
  5. STEP05

    Redeploy to bake the frontend

    Run Deploy AWS (Caddy) again with caddy_tls=false (or true only after DNS—your choice). The workflow rebuilds the frontend with NEXT_PUBLIC_* build-args; skipping this leaves the old IP in the client bundle.

  6. STEP06

    Point DNS at Hostinger (or your registrar)

    Create A records for apex @ and www to ec2_public_ip. We use Hostinger for kitepdf.pro; TTL as low as your registrar allows while cutting over. Do not enable a CDN proxy in front of the origin until you understand ACME HTTP-01.

    Point DNS at Hostinger (or your registrar)yaml
    # Illustrative — use your registrar UI
    # @ A <ec2_public_ip>
    # www A <ec2_public_ip>
  7. STEP07

    Wait, then deploy with automatic HTTPS

    Use dig or a browser from another network until apex resolves to the instance. Then Run workflow with caddy_tls=true (SITE_HOST + ACME_EMAIL secrets must already exist). Caddy obtains Let's Encrypt certs into the caddy_data volume.

    Wait, then deploy with automatic HTTPSyaml
    dig +short kitepdf.pro A
    # GitHub Actions: Deploy AWS (Caddy), caddy_tls=true
  8. STEP08

    Verify production edge

    Confirm TLS, HSTS, www redirect, auth cookies on the apex, API under /core/v1, health and docs paths, and an upload/download through presigned S3.

    Verify production edgeyaml
    curl -sfI https://kitepdf.pro/
    curl -sf https://kitepdf.pro/core/health
    curl -sfI https://kitepdf.pro/core/docs | head -n 5
    curl -sfI https://www.kitepdf.pro/ | head -n 5
09

Production lessons from the cutover

SymptomRoot causeFix pattern
ACME fails / no certDNS not at this host, :80 blocked, or SITE_HOST mismatchFix DNS/SG; check Caddy logs; confirm SITE_HOST equals the apex name
Login works on IP, fails on domainBETTER_AUTH_URL / NEXT_PUBLIC_* still on http://IPUpdate secrets, rebuild frontend, restart frontend
JWT rejected after cutoverAUTH_ISSUER/AUDIENCE still HTTP or wrong slashAlign gateway env; restart api-gateway
Browser CORS errors on uploadS3 CORS missing https originscors_extra_origins in tfvars → terraform apply
Port 80 already allocatednginx and Caddy both runningStop/remove the other edge compose stack
Certs vanish after redeploycompose down -v wiped caddy_dataNever -v on prod; restore volume or re-issue once
Auth flakes after instance stop/startPublic IP changed; DNS or CORS staleUpdate DNS A records; terraform apply for CORS
10

What we still have not done

HTTPS and a domain closed the largest gap called out in Part 3. We still have not added an Elastic IP, remote Terraform state, autoscaled workers, or a second AWS environment for staging. The host remains a single blast radius; queues are still the intended scale-out point for PDF work.

  • Elastic IP or other sticky addressing so DNS survives stop/start without edits.
  • Tighter ssh_ingress_cidr and ongoing secret rotation drills.
  • Remote Terraform state and a clearer promote-from-staging path.
  • Worker horizontal scaling across hosts while keeping the edge on one or more proxies.
11

What to read next

Revisit Part 3 for the full Terraform module, SSM layout, and Neon migration workflow. Revisit Part 2 when JWT validation fails across environments—issuer, audience, and JWKS URL mistakes look the same on HTTPS as they did on HTTP. Future parts can cover local fidelity (prod-localstack with Caddy) and horizontal scaling once the public edge is no longer the unstable variable.