Files
adr-sml/docs/06-deployment.md
T
Pouya Lajevardi 19f7226661
Build and deploy / build-and-deploy (push) Failing after 5s
chore: project scaffold, specs, and working record
2026-08-26 08:51:16 -04:00

11 KiB

06 — Deployment and cutover

Authority: AGENTS.md §3 D3 (git + GitHub Actions → existing S3/CloudFront) and D11 (build everything, one clean cutover). Existing infrastructure: AWS-Hosting-Guide.md.


Topology

GitHub push to main
  └─ GitHub Actions
       ├─ npm ci && npm run build        → ./dist
       ├─ assume AWS role via OIDC        (no stored keys)
       ├─ aws s3 sync ./dist s3://<bucket>
       └─ cloudfront create-invalidation
Namecheap DNS → CloudFront → S3 (OAC)
API Gateway → Lambda → DynamoDB / SES     (intake, unchanged path)

DNS is at Namecheap, not Route 53 [verified 2026-08-25]. Nothing in the pipeline touches DNS. Certificate renewal is ACM-automatic as long as the validation CNAME stays in place at Namecheap — do not delete it.

CI runs on Gitea, not GitHub

AGENTS.md D3 as amended, 2026-08-26: self-hosted Gitea, repo adr-sml, local clone at /Users/pouya/Dev/Websites/adr-sml.

The live pipeline is .gitea/workflows/deploy.yml. Gitea Actions speaks GitHub Actions syntax, so it is a near-direct port — the build steps, the two-pass sync, and the cache headers are unchanged. .github/workflows/deploy.yml stays in the repo as the OIDC reference in case the project ever moves.

The one real difference: no OIDC

Gitea is not an AWS OIDC provider. There is no role to assume, so deploys authenticate with a scoped IAM user whose access key lives only in the repository's Gitea secrets.

This is a genuine step down in security from the GitHub setup, and it should be treated as one. The mitigations are the policy scope and the rotation schedule.

Create the user:

  1. IAM → Users → adr-sml-deploy. Programmatic access only — no console password, no MFA device, no group membership.
  2. Attach this inline policy and nothing else. Substitute the real bucket name, account ID, and distribution ID from scripts/aws-discover.sh:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListSiteBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::BUCKET_NAME"
    },
    {
      "Sid": "WriteSiteObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::BUCKET_NAME/*"
    },
    {
      "Sid": "InvalidateOneDistribution",
      "Effect": "Allow",
      "Action": "cloudfront:CreateInvalidation",
      "Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
    }
  ]
}

No s3:*. No cloudfront:*. No wildcard resources. If a deploy step needs a permission this policy lacks, the correct response is to question the step, not to widen the policy.

  1. Create an access key. Copy it once — AWS will not show the secret again.

Gitea configuration

Repository → Settings → Actions → Secrets:

Name Value
AWS_ACCESS_KEY_ID from the IAM user
AWS_SECRET_ACCESS_KEY from the IAM user

The real values (captured 2026-08-26, aws-inventory.txt):

Variable Value
AWS_REGION ca-central-1
S3_BUCKET adr-smlcompany-site
CLOUDFRONT_DISTRIBUTION_ID E1OK7G98KNKUTA
INTAKE_ENDPOINT https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com
BOOKING_URL (empty — parked, R6)

IAM policy substitutions: BUCKET_NAME = adr-smlcompany-site, ACCOUNT_ID = 327082975128, DISTRIBUTION_ID = E1OK7G98KNKUTA.

Read this before creating the key. Account 327082975128 is shared across meshkinilaw.ca, demesne.media, orynenergy.ca, lajirugs.ca, and mlp-clientdb-prod-backups — a law firm's client-database backups. A static deploy key for a marketing site lives in the same account. The scoped policy is what keeps a compromised Gitea runner from reaching any of that. Do not widen it, and never put the user/pouya credentials in CI.

Repository → Settings → Actions → Variables (not secrets — these are not sensitive, and keeping them as variables means they appear in run logs where they are useful for debugging):

Name Value
AWS_REGION e.g. ca-central-1
S3_BUCKET the site bucket
CLOUDFRONT_DISTRIBUTION_ID the E... ID
INTAKE_ENDPOINT API Gateway invoke URL
BOOKING_URL once chosen (Q5)

A runner must exist

Gitea Actions needs act_runner registered to this repository or its organisation, and Actions enabled both site-wide in app.ini ([actions] ENABLED = true) and per-repository. Without a runner the workflow queues silently and never runs — which looks exactly like a broken pipeline.

The workflow installs the AWS CLI if the runner image lacks it, and runs aws sts get-caller-identity before touching anything, so a credential problem fails loudly and early rather than halfway through a sync.

Key rotation — an operational obligation

Rotate adr-sml-deploy quarterly. OIDC would have made this unnecessary; with a static key it is a standing task:

  1. Create a second access key on the same user.
  2. Update the Gitea secrets.
  3. Run the workflow and confirm it succeeds.
  4. Delete the old key. Rotation that leaves the old key active is not rotation.

Set a calendar reminder. A key that is never rotated is the failure mode this whole section exists to bound.

Finding the AWS identifiers

scripts/aws-discover.sh collects everything Q10 needs — bucket, distribution ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning is on. Read-only; every call is a list or describe.

chmod +x scripts/aws-discover.sh
./scripts/aws-discover.sh > aws-inventory.txt

The output contains resource names and IDs but no secrets.

Why OIDC and not access keys

The alternative is a long-lived AWS_ACCESS_KEY_ID in GitHub secrets: a credential that never expires, is invisible once set, and grants its permissions to anyone who can reach the repository. OIDC issues a short-lived token per run, scoped to this repository and this branch.

One-time setup:

  1. IAM → Identity providers → add OIDC provider token.actions.githubusercontent.com, audience sts.amazonaws.com.
  2. Create role adr-site-deploy trusting that provider, with a condition on token.actions.githubusercontent.com:sub equal to repo:<org>/<repo>:ref:refs/heads/main (Q9).
  3. Attach a policy granting only: s3:PutObject, s3:DeleteObject, s3:ListBucket on the site bucket, and cloudfront:CreateInvalidation on the one distribution. Nothing else. No s3:*, no cloudfront:*.
  4. Store the role ARN, bucket name, and distribution ID as repository variables (they are not secrets), and reference them in the workflow.

Cache policy

The mistake to avoid is caching HTML aggressively — a stale index page is a site that does not update.

Pattern Cache-Control
*.html public, max-age=0, must-revalidate
/_astro/* (hashed) public, max-age=31536000, immutable
Fonts public, max-age=31536000, immutable
Images public, max-age=604800
robots.txt, sitemap*.xml public, max-age=3600

Sync in two passes: hashed assets first with the long TTL, then HTML with the short one. Uploading HTML last means a user never fetches a new page whose assets have not landed yet.

Invalidate /* on deploy. At this traffic volume the cost is nil, and partial invalidation paths are a reliable source of confusing bugs.

CloudFront configuration

  • Origin: S3 with Origin Access Control, bucket not public. The guide's Part 2.2 bucket policy already does this — verify it was not loosened.
  • Redirect HTTP → HTTPS. TLS 1.2 minimum.
  • Default root object index.html.
  • Custom error response: 404 → /404.html with response code 404, not 200. Returning 200 for a missing page tells crawlers every bad URL is real content, and it is the single most common misconfiguration in this stack.
  • Compression on. Response-headers policy from 05-backend-spec.md.
  • A CloudFront Function for trailing-slash normalisation, so /about and /about/ do not both resolve as separate indexable URLs.

Branch model

main is production; every push deploys. Work on short-lived branches, open a PR, let CI build and run Lighthouse, merge.

Pull request checks (blocking): npm run build · astro check · lint · Lighthouse CI against the budgets in 04-seo-spec.md · link check.

Tag every production deploy v<year>.<n> so a rollback has something to name.

Rollback

  1. Re-run the workflow at the last good tag, or
  2. git revert and push, or
  3. Restore from S3 object versioning — enable versioning on the bucket if it is off; it is the difference between a rollback and a rebuild.

Then invalidate /*.

Cutover checklist — D11 is a single shot, so run all of it

Content and compliance

  • Every claim traced to AGENTS.md §4 Verified
  • No TODO(pouya) remains in any shipped page
  • No matter counts, rates, dollar figures, or testimonials anywhere
  • Q.Arb described as in progress everywhere it appears
  • /fees/ carries real numbers (Q4) or the page does not ship
  • Privacy policy matches the backend as actually built

Technical

  • Lighthouse ≥ 95 mobile on /, /about/, a practice page, an article
  • Every page renders fully with JavaScript disabled
  • curl of each URL returns real content, not a shell
  • All internal links resolve; no orphan pages
  • Sitemap generated and correct; robots.txt served, not 403
  • Rich Results Test passes; OG previews render in LinkedIn and Slack
  • 404 returns a 404 status
  • Security headers present (securityheaders.com A or better)
  • SES identities verified for sending (Q18) — aws sesv2 get-email-identity --email-identity smlcompany.ca and confirm VerifiedForSendingStatus: true
  • SES out of the sandbox (Q19) — aws sesv2 get-account --query 'ProductionAccessEnabled'. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails
  • Intake form tested end to end: DynamoDB record written to adr-intake-submissions, both emails delivered to a real inbox, TTL set
  • Booking link works, including the no-JavaScript fallback
  • Favicon set complete
  • Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
  • Tested at 320 px and at 200% zoom

Infrastructure

  • S3 versioning enabled
  • Bucket not publicly readable; OAC in force
  • ACM certificate valid; Namecheap validation CNAME still present
  • CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
  • Billing alarm still active (guide Part 0.3)

Post-cutover, same day

  • Sitemap submitted to Google Search Console and Bing Webmaster Tools
  • Live site fetched as an anonymous crawler to confirm indexable content
  • LinkedIn profile and ADRIC/ADRIO listings updated to point here
  • Archive the old single-file build to _archive/ — do not delete it
  • AGENTS.md Change Log entry recording the cutover