Introduction
I ran manuel-vogel.de on Hugo for three years. Static site, S3, CloudFront, a
self-mutating CDK pipeline I built from scratch in 2023. It
worked, it was cheap, did the job, and I was proud of it. But it could only ever serve files. No forms that do anything (well, I had one, which got a bunch of spam), no real lead capture, nothing server-side, nothing really dynamic.
So this summer I rebuilt the whole thing: Next.js 16 on AWS Amplify Hosting,
a proper lead pipeline with DynamoDB and SES, and a new domain,
vogel-johnson.com. This post is the technical walkthrough, with the AWS side
front and center, since that's the part that actually took thought.
The challenge
The old stack, in one line: Hugo build -> Docker bundling assets in CDK ->
CodePipeline -> S3 -> CloudFront, with a hand-rolled Lambda@Edge function to
redirect / to /index.html and fixed replies for 403/404. It was
self-contained, which I liked, but every extra feature meant more
Lambda@Edge code, and this is a genuinely unpleasant place to debug
anything: no environment variables, only js, and a small payload only.
What I actually wanted this time:
- A real contact form and newsletter with double opt-in, not a
mailto:link. - Content still built at compile time, no client-side markdown rendering, no waiting on an SSR render for a blog post that never changes.
- A migration path that didn't quietly erase three years of SEO.
- Again, something I could run entirely on my laptop before touching a single real AWS resource.
None of that fits cleanly into "static site on S3." It needs actual server
compute, and I didn't want to hand-roll that compute layer myself again. Also, I didn't want to code the dev environment myself again. Back then in 2023, it was a good exercise, but this time I wanted most things out of the box. If you are interested, you can read the old blog post. For now, let's jump right into the solution.
The solution
Framework and hosting
I picked Next.js 16 App Router with Tailwind Plus UI blocks, restyled into my
own dark-first design system, and deployed it on AWS Amplify Hosting with
managed SSR compute rather than the CloudFront/S3/Lambda@Edge stack I'd built
by hand before. Instead of owning the
pipeline (CodePipeline, custom Docker build, Lambda@Edge redirects), Amplify
owns the build-and-deploy loop, and I just tell it what to build via
amplify.yml.
version: 1
frontend:
phases:
preBuild:
commands:
# Pin Node to .nvmrc (22) instead of Amplify's default image Node; raise the heap so
# the Next build does not OOM on large content sets.
- nvm install
- nvm use
- export NODE_OPTIONS=--max-old-space-size=8192
# Decrypt the branch-appropriate committed SOPS env file (.env.production.enc on `main`,
# .env.development.enc on every other branch) with the KMS key the Amplify service role
# can decrypt, and stamp in the values that must vary per build (LEADS_TABLE_NAME always;
# SITE_BASE_URL + SITE_INDEX on preview branches). See scripts/amplify-branch-env.sh -
# Amplify app/branch env vars are build-time only and never reach the SSR compute runtime,
# so runtime config is delivered via the `.env` this produces (Next.js loads it at startup).
- SOPS_VERSION=v3.13.1
- curl -sSfL -o /tmp/sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
- chmod +x /tmp/sops && sudo mv /tmp/sops /usr/local/bin/sops
- bash scripts/amplify-branch-env.sh # <- does the branch magic
- npm ci --legacy-peer-deps
build:
# BUILD_STANDALONE intentionally unset here: Amplify WEB_COMPUTE expects the default
# .next output, not the Docker standalone bundle (see next.config.ts + Dockerfile).
commands:
- npm run build
artifacts:
# baseDirectory for Amplify Next.js SSR (WEB_COMPUTE) is .next.
baseDirectory: .next
files:
- "**/*"
cache:
paths:
- node_modules/**/*
- .next/cache/**/*Content is still build-time. Blog posts, case studies, and legal pages are
MDX files loaded through a typed content API, compiled with @next/mdx and
rehype-pretty-code for syntax highlighting, no client-side highlighter, no
ISR, no edge streaming. Amplify's managed Next.js runtime doesn't support
those anyway, and I didn't need them: a blog post doesn't change after I hit
publish.
The lead pipeline: DynamoDB and SES via CDK
This is the part the old site never had. Contact form, newsletter subscribe
with double opt-in, unsubscribe, gated content downloads, training requests:
all real route handlers under src/app/api/, all writing to a DynamoDB table
and sending mail through SES.
Infrastructure is CDK, eu-central-1, split into small constructs:
cdk/lib/
leads-table.ts # DynamoDB table for lead records
sending-identity.ts # SES domain identity + DKIM
ssr-hosting.ts # the Amplify App/Branch resources
sops-key.ts # KMS key for encrypted env
edge-protection.ts # WAF (optional)
observability.ts # alarmsLocally, none of this touches real AWS infrastructure. I run MiniStack, a free,
MIT-licensed LocalStack replacement, as a single Docker container exposing
DynamoDB and SES on port 4566. The same application code targets either backend;
the only difference is whether AWS_ENDPOINT_URL is set. Sent mail during
local dev is visible at /_ministack/ses/messages, which the Cypress
E2E suite actually asserts against, no fake mail server, no manual inbox
checking.
The data model is one DynamoDB table with composite keys, not one table per
lead type: subscribers, confirm tokens, contact messages, and gated-content
leads each get their own record-type prefix on the same partition/sort key
pair, plus a secondary index so the admin view can list subscribers by
status without a full table scan. Confirm tokens carry a ttl field a
couple of days out, so DynamoDB expires them on its own, no cleanup job
needed. One table, one billing line, and the sort key does the filtering.
Confirming a subscription is the one place a naive read-then-write would
have created a real bug: two near-simultaneous hits on /api/confirm, a
double-clicked email link, or a bot replaying it, could both read "not yet
confirmed" and both decide to confirm. Instead it's a single
TransactWriteItems call that flips the subscriber to confirmed and marks
the token used atomically, each write conditioned on the item's prior
state. If the transaction gets canceled because someone already claimed it,
the handler re-reads and returns whichever state won, instead of erroring
out on a user who already succeeded once.
Unsubscribe tokens are stateless on purpose: an HMAC-SHA256 of the lowercased email against a server secret, not a stored token, so there's no reverse lookup and no extra item to write. GDPR retention rides the same TTL field instead of a cron job. Contact submissions and gated leads expire after roughly two years; a subscriber record only gets a TTL once they unsubscribe, kept an extra year as evidence the withdrawal happened.
Two smaller, honest decisions worth naming. Captcha verification has a local/CI escape hatch that skips the real Cloudflare Turnstile call and short-circuits straight to a pass, which is what keeps automated tests from depending on a live third-party call succeeding on every run, guarded so it can only ever be true outside production.
Guardrails: CDK-NAG, WAF, and the alarm I didn't add
Every stack has to pass CDK-NAG and cfn-lint before it's allowed to deploy
(npm run validate), and the suppression list stays short on purpose: three
suppressions, each scoped to one exact resource, not a blanket allow on the
stack. AWS Backup's auto-generated service role uses an AWS-managed policy
(unavoidable, that's how Backup works), Amplify's build role needs
AdministratorAccess-Amplify (scoped only to the hosting construct, since
the actual SSR runtime role stays least-privilege), and the DynamoDB grant
for GSI access uses a wildcard scoped by regex to /index/*, not to the
whole table.
WAF is opt-in, -c enableWaf=true, and costs nothing by default. Turned on,
it's a regional WebACL (Amplify's compute is regional, not
CloudFront-at-the-edge, so it can't live in the usual us-east-1 WAF scope)
running AWS's managed common rule set plus a rate-based rule that blunts
per-IP request bursts, for a modest monthly cost. Let's see how the site evolves and if I need to add WAF. On the previous site, I saw many calls to /admin.php, etc.
export class EdgeProtection extends Construct {
readonly webAcl?: CfnWebACL;
constructor(scope: Construct, id: string) {
super(scope, id);
const flag = this.node.tryGetContext("enableWaf");
if (flag !== true && flag !== "true") {
return;
}
this.webAcl = new CfnWebACL(this, "WebAcl", {
defaultAction: { allow: {} },
scope: "REGIONAL", // we are not in us-east-1, so this is a regional WAF for Amplify Hosting SSR
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "homepageTwplusWebAcl",
sampledRequestsEnabled: true,
},
rules: [
{
name: "AWSManagedRulesCommonRuleSet",
priority: 0,
overrideAction: { none: {} },
statement: {
managedRuleGroupStatement: {
vendorName: "AWS",
name: "AWSManagedRulesCommonRuleSet",
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "commonRuleSet",
sampledRequestsEnabled: true,
},
},
// as of now ... let's see if we need more rules...
{
name: "RateLimitPerIp",
priority: 1,
action: { block: {} },
statement: {
rateBasedStatement: {
limit: 1000,
aggregateKeyType: "IP",
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: "rateLimitPerIp",
sampledRequestsEnabled: true,
},
},
],
});
}
}For alarms: SES bounce and complaint rate alarms tuned to catch a sending reputation problem well before AWS's own thresholds would throttle or suspend sending, both single-period so they don't wait around before paging, and both configured not to treat missing data as a breach. A separate EventBridge rule watches for failed Amplify deployment events and feeds the same SNS topic, since there's no CloudWatch metric for Amplify build status. What I deliberately didn't add is an SSR 5xx alarm: Amplify's managed runtime doesn't expose a stable per-app 5xx metric, and an alarm on a number that doesn't reliably mean what you think it means is worse than no alarm at all. I'd rather have a visible gap than a false sense of coverage. I did add one boring but necessary thing: a dedicated log group for the SSR compute logs with a short retention window and automatic cleanup, because Amplify's default logging has no retention limit and will happily accumulate forever otherwise.
Secrets: SOPS in, CloudFormation dynamic references where SOPS can't reach
Two different secret-handling patterns show up here, and it's worth being precise about which is which.
App runtime config and secrets live together in a single SOPS-encrypted file,
.env.production.enc, committed to the repo. It's decrypted to .env in the
Amplify build's preBuild phase using a KMS key that only the Amplify service
role can decrypt:
sops --input-type dotenv --output-type dotenv -e .env.production > .env.production.enc
git add .env.production.encInfrastructure-level secrets (the GitHub token Amplify needs for CI, the
Basic Auth passwords for pre-launch and preview branches) go through
CloudFormation dynamic references instead:
{{resolve:secretsmanager:...}}, resolved at deploy time by whoever is
running cdk deploy, not by Amplify at runtime. That distinction matters for
IAM: the SSR compute role has zero secretsmanager: permissions. It never
needs them, because it never reads Secrets Manager. CloudFormation already
baked the resolved value into the Amplify branch config before the app ever
ran.
That second pattern also produced an ugly bug.
Turning off Basic Auth on the main branch should have been one flag,
-c siteBasicAuth=false. It wasn't, twice:
@aws-cdk/aws-amplify-alpha'sBranchconstruct just omitsBasicAuthConfigfrom the template whenbasicAuthis undefined, instead of emittingenableBasicAuth: false. CloudFormation's Amplify handler treats an omitted property as "no change" on update, so the already-live branch kept Basic Auth on even after redeploying with the flag off. Fixed with an explicitaddPropertyOverrideon the L1 resource.- Second attempt failed validation entirely:
AWS::Amplify::Branch's real API schema requiresUsernameandPasswordonBasicAuthConfigregardless ofEnableBasicAuth. My override only sentEnableBasicAuth: false. Fix was to keep sending the same secret-backed username/password dynamic references as the enabled path, just with the flag flipped: inert once disabled, but present.
Two failed deploys for what should have been a boolean. That's the kind of
thing you only find by actually running cdk deploy against the real API,
not from reading the L2 construct's TypeScript types. But it is still in alpha state, so there is room for improvement. For now, I am fine with the workaround.
Branch previews without touching production
Once the lead pipeline had a real database behind it, I wanted feature
branches to preview against their own data, not production's. One more
cdk deploy wired up Amplify's auto branch creation for feat/*, fix/*,
and chore/* branches: a separate leads table for preview traffic, its own
Basic Auth credentials, its own SOPS-encrypted env file, and auto subdomain
creation turned off for the custom domain, so no preview branch can ever
pick up a public subdomain by accident. Previews only ever exist at
*.amplifyapp.com.
export class SsrHosting extends Construct {
readonly app: App;
readonly computeRole: Role;
constructor(scope: Construct, id: string, props: SsrHostingProps) {
super(scope, id);
// ...
// Only feat/*, fix/*, and chore/* branches are auto-connected, built as Next.js SSR, and
// pointed at the shared preview table - no `cdk deploy` per branch. Scoped to these
// prefixes (rather than "all repository branches") so bot/throwaway branches
// (dependabot/renovate, ad-hoc spikes) never get a build against the shared preview table.
autoBranchCreation: {
patterns: ["feat/*", "fix/*", "chore/*"],
basicAuth: devBasicAuth,
autoBuild: true,
pullRequestPreview: false,
},
// Deleting the branch on GitHub removes its Amplify preview - no manual cleanup.
autoBranchDeletion: true,
// ...
}
}The one non-obvious part: the table name only reaches the SSR runtime
through the .env a build writes. Updating the Amplify app resource via
CDK doesn't rebuild the already-deployed main branch, so after renaming
the production table, I had to trigger a fresh build of main before it
would pick up the change, otherwise, it keeps writing to the orphaned old
table.
The hydration bug that looked like flaky tests
The strangest bug of the whole migration didn't show up in manual testing. It showed up as Cypress randomly reporting form inputs as detached from the DOM mid-keystroke, intermittent, only under E2E, never reproducible by hand.
The actual cause was the dark-mode theme script: a small inline <script>
in <head> that reads localStorage and sets the dark class before first
paint, so there's no flash of the wrong theme. Very standard pattern. What
isn't standard is how React 19 treats it: that inline script renders into
the server HTML, but React's client-side render tree never includes it at
all. Scripts inside components simply don't execute on the client render
pass. That means the server tree and the client tree differ in element count
from the very first render, which trips React error #418. React's response
to #418 is to throw away the entire server-rendered DOM and regenerate it
from scratch on the client. I confirmed it with a MutationObserver: the
footer got removed (6416 characters) and re-added (6376 characters) 122
milliseconds after load, a full re-hydration nobody asked for, right as a
real user, or Cypress, might be typing into a field that gets swapped out
from under them.
Next.js's own documented fix for this pattern, type="text/plain" plus
suppressHydrationWarning, didn't work, at least not on Next 16.1 with
React 19.2. The actual fix was switching the theme script to next/script
with strategy="beforeInteractive", which runs through Next's own script
injection path instead of React's reconciliation, so it's never part of the
tree React compares in the first place. No flash of unstyled content either:
the dark class now lands around 72 milliseconds in, well before first
contentful paint at roughly 1000 milliseconds.
A second, unrelated source of the same flakiness was hiding in the same test
runs: the Cloudflare Turnstile widget script was loading during E2E
regardless of the disable flag, injecting its own DOM about 207 milliseconds
after load, an entirely separate race dressed up as the same symptom. And
there was a pre-existing workaround in cypress/support/e2e.ts that
blanket-suppressed React errors 418, 423, and 425, which had been quietly
eating the real signal the whole time. Removing that suppression was part of the
actual fix, not an afterthought, so the next hydration regression fails
loudly instead of vanishing into a "known flaky" bucket.
I am happy to have the e2e test suite next to the unit tests, to ensure after each change the functionality remains intact.
cypress/e2e
├── calendar-booking.cy.ts
├── card-image-link.cy.ts
├── community-work-meetup-feed.cy.ts
├── consent-banner.cy.ts
├── contact.cy.ts
├── ga4-consent-gating.cy.ts
├── gallery.cy.ts
├── gated-content.cy.ts
├── hotjar-consent-gating.cy.ts
├── hydration.cy.ts
├── mermaid-render.cy.ts
├── post-image-fullscreen.cy.ts
├── subscribe-confirm.cy.ts
├── testimonial-handle-link.cy.ts
├── tools-filter.cy.ts
├── training-request.cy.ts
└── unsubscribe.cy.ts
The domain cutover I looking forwards to
New domain, vogel-johnson.com, associated to the Amplify app with
app.addDomain(). Since DNS already lived in Route53, Amplify auto-discovers
the zone by name and writes both the ACM DNS-validation record and the
root/www alias records itself, no manual CNAME pasting.
One AWS quirk worth knowing: the default *.amplifyapp.com domain cannot
be disabled. There's no toggle, no API for it. So instead of deleting it, I
308-redirect it to the real domain from inside the app's redirects()
config, scoped to exclude _next/ and api/ so it doesn't interfere with
platform health checks. Both that redirect and the legacy-domain one below
had to live in Next's redirects() config rather than Amplify's own
redirect rules, because Amplify's native rules only match on path and
country, not host, and matching by host is exactly what both of these need.
For the legacy manuel-vogel.de domain, I mapped the full old URL scheme onto
the new one instead of a blanket redirect-to-homepage:
Old (manuel-vogel.de) | New (vogel-johnson.com) |
|---|---|
/posts, /posts/:slug | /blog, /blog/:slug |
/works, /works/:slug | /contributions, /contributions/:slug |
/services | /solutions |
/tags/:tag | /blog/tag/:tag |
| anything else | / (soft fallback) |
Derived it straight from the live manuel-vogel.de/sitemap.xml, cross-checked
every slug against the migrated content so nothing 404s. One thing:
skipTrailingSlashRedirect: true had to go in next.config.ts, because
Next's built-in trailing-slash-strip redirect otherwise fires before custom
redirects are evaluated, and every single Hugo URL ends in /, so without that
flag they'd all get swallowed into a same-host redirect loop instead of
crossing to the new domain.
And a genuinely surprising one: Google Search Console's Change of Address
wizard validates the homepage redirect status code itself, not just
whether the redirect works. I originally emitted a 308 (Next's default for
permanent: true), confirmed with curl that it worked correctly, and GSC
still failed the check with a red X. Google's ranking algorithm treats
301 and 308 identically; GSC's own validator apparently doesn't. Switching
that one redirect rule to a literal statusCode: 301 fixed it. Every other
redirect on the site stayed 308, since GSC only checks that specific
migration flow.
Basic Auth had to come off before the legacy domain went live, too: it's enforced at the Amplify CDN layer, ahead of any app code, so Googlebot would have hit 401s instead of 301s if I'd sequenced it the other way.
Conclusion
Tearing down the old CDK pipeline afterward turned up its own small mess: a
production S3 bucket kept alive by RemovalPolicy.RETAIN with no
auto-delete, two orphaned ACM validation CNAMEs left behind by a custom
resource on destroy, and about 7.4 GB of stale CodePipeline build artifacts
sitting in S3 for no reason. Worth checking for on any teardown: cdk destroy only removes what the stack directly owns, not everything the stack
ever touched.
If I total up the git history for the actual build (excluding the original Next.js scaffolding and dependency bumps), the design, content migration, and lead pipeline slices came to roughly ten hours of active work spread across a week. That number is a floor, not a ceiling: the infrastructure and DNS slices show up as single-commit sessions, because almost all of the CDK thinking happens before the one commit that deploys it, so the real time spent on those two slices is close to invisible to git.
Would I migrate the same way again? Yes, to Amplify managed SSR over hand-rolling my own pipeline, I'd rather debug a route handler than Lambda@Edge ever again. The one thing I'd do earlier next time is write the legacy redirect map before the new site design is even final, since it's the one piece that depends on nothing else and blocks the whole cutover at the end.
As always, I used my pet project to learn a few new things, and this time it was Next.js 16 App Router, Amplify Hosting SSR, and a few new AWS services I hadn't used before. Let's see how the site evolves over the next year, and if I need to add WAF or other security measures and probably a whole admin UI with user management.For now, it's a clean, fast, and functional site with a proper lead pipeline and no more Lambda@Edge headaches.
This post was written with AI assistance and verified plus enhanced by a human.



