Introduction
You've built a small site. A blog, a landing page, the frontend of a side project. And now you want it on AWS. Not on Vercel, not on Netlify, but in your own account, on your own bill.
Back in 2023, I did exactly that with my Hugo blog and wrote up every roadblock in hugo: all on AWS. The quick recap: a self-mutating cdk pipeline, CloudFront, S3, a WAF I later swapped for basic auth, and a protected dev subdomain. It was a fantastic learning project. It was also a lot of moving parts for what is, in the end, a blog.
The site you are reading this on is now a Next.js app, and it runs on AWS too, but with a fraction of that code. In this post, I want to share the options you have now, and which one I'd reach for when.
One naming note upfront: I use "SPA" loosely here. A static Hugo blog isn't a single-page app, and a Next.js site can be static, server-rendered, or both. But the hosting question on AWS has the same shape for all of them, so I'm putting them in one bucket.
Let's get going.
The problem
Static Hugo site or Next.js app, the list of things you actually need is always the same:
- a custom domain with SSL
- a CDN in front, which on AWS means CloudFront
- CI/CD, so a
git pushdeploys - a protected preview or
devstage, usually behind basic auth - all of it in your own AWS account, as infrastructure-as-code if possible
In 2023 I wired every single one of those by hand. The interesting question for a new project is: how much of that do you still have to build yourself in 2025? The short answer is much less than you'd think.
Let's walk through the options, roughly from least effort to most control, and the criteria that matter: how much you want to build yourself, how much control you need, and how much AWS plumbing you want to own.
The solutions
AWS Amplify Hosting
This is the managed path. You point Amplify at your GitHub repo, and it builds and deploys on every push. It works for static sites and for server-rendered ones. If you want the simplest path, start here.
For a plain static Hugo blog, it really is a few clicks in the console. Adam Divall has a nice step-by-step walkthrough: connect the repo, pick the branch, confirm the build settings, done. Custom domain and certificate are handled for you, so you trade control for speed.
For Next.js, Amplify has a WEB_COMPUTE platform that runs the SSR server. That's what this site uses. But I don't click it together in the console. I define the Amplify app in cdk with the @aws-cdk/aws-amplify-alpha construct, so the whole thing stays IaC:
// cdk/lib/ssr-hosting.ts (trimmed)
this.app = new App(this, 'App', {
appName: 'homepage-twplus',
platform: Platform.WEB_COMPUTE, // Next.js SSR
sourceCodeProvider: new GitHubSourceCodeProvider({
owner: 'yourcompany',
repository: 'your-webapp',
oauthToken: SecretValue.secretsManager('amplify-github-token'),
}),
// only construct-derived values live here
environmentVariables: {
LEADS_TABLE_NAME: props.table.tableName,
// and others ...
},
});
const mainBranch = this.app.addBranch('main', { autoBuild: true, basicAuth });
// the alpha Branch construct doesn't expose `framework`, so set it on the L1
(mainBranch.node.defaultChild as CfnBranch).framework = 'Next.js - SSR';Remember that basic auth on the dev stage I fought a whole WAF for in 2023? It's now one prop:
const basicAuth = BasicAuth.fromCredentials(
'preview',
SecretValue.secretsManager('site-basic-auth-password'),
);
this.app.addBranch('main', { autoBuild: true, basicAuth });The actual build steps live in the repo root amplify.yml. Mine pins Node to .nvmrc, bumps the heap so the build doesn't OOM on a large content set, and decrypts the secrets. See what the amplify.yml looks like with explanations:
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 committed SOPS env file (.env.production.enc -> .env) with the KMS key the
# Amplify service role can decrypt (SOPS reads the key ARN from the file metadata).
- export 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
- sops --input-type dotenv --output-type dotenv -d .env.production.enc > .env
# Amplify app/branch env vars are build-time only and never reach the SSR compute
# runtime; runtime config is delivered via this .env (Next.js loads it at startup).
# LEADS_TABLE_NAME is construct-derived (CDK app env var, not in the SOPS file), so
# append it here or the lead pipeline falls back to the "leads-local" default at
# runtime and DynamoDB writes get AccessDenied (HTTP 500 on /api/<endpoint>).
- echo "LEADS_TABLE_NAME=$LEADS_TABLE_NAME" >> .env
- 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/**/*
Secrets with sops
Amplify app and branch environment variables are build-time only. They do not reach the Next.js SSR runtime. And I don't want plaintext secrets sitting in the console or, worse, in the repo. So I use sops with a KMS key. The encrypted .env.production.enc is committed, the plaintext .env.production is gitignored. A small .sops.yaml tells sops which key to use:
creation_rules:
- path_regex: \.env\.(production|development)$
key_groups:
- kms:
- arn: arn:aws:kms:eu-central-1:<account-id>:alias/homepage-sopsThe KMS key itself is created in the same cdk app, and the Amplify build role is granted decrypt on it. Then the build does one line:
# amplify.yml (preBuild, trimmed)
- sops --input-type dotenv --output-type dotenv -d .env.production.enc > .envNext.js loads that .env at startup. The secret lives in git, encrypted, readable only by the build role. No console clicking, no secret sprawl, and the diff is reviewable.
AWS Solutions Constructs: aws-s3-static-website
If your site is purely static, so a compiled Hugo public/ folder or a Next.js out/ export, and you want CloudFront plus S3 without hand-wiring the origin access, the bucket policy, and the error responses, AWS ships an official building block for exactly that: the aws-s3-static-website use case in aws-solutions-constructs.
It's CloudFront, S3, and a Lambda-backed custom resource that copies the content in. You run cdk deploy, then grab the websiteURL output. This is the middle ground: IaC and control, without building a whole pipeline.
Here you do not have a pipeline running in the AWS cloud, but rather you run hugo build locally and point the stack towards the output folder, which is website-dist in this case:
import { Construct } from 'constructs';
import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';
import { CloudFrontToS3 } from '@aws-solutions-constructs/aws-cloudfront-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as path from 'path';
export class S3StaticWebsiteStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const construct = new CloudFrontToS3(this, 'CloudFrontToS3', {});
new s3deploy.BucketDeployment(this, 'DeployWebsite', {
sources: [s3deploy.Source.asset(path.join(__dirname, 'website-dist'))],
destinationBucket: construct.s3Bucket!,
});
new CfnOutput(this, 'websiteURL', {
value: 'https://' + construct.cloudFrontWebDistribution.domainName
});
}
}cdk-nextjs
Amplify's Next.js support is good, but it's a black box, and it doesn't cover every feature. If you want all Next.js features, the App and Pages routers, ISR, image optimization, streaming, and running on infrastructure you fully control, then cdk-nextjs from cdklabs is the construct.
It gives you a choice of architecture:
NextjsGlobalFunctions: Lambda behind CloudFront, serverless, pay per requestNextjsGlobalContainers: Fargate behind an ALB and CloudFront, for predictable traffic and no cold starts- regional variants for when you can't use CloudFront, for example GovCloud
Static assets go on S3, and the cache uses S3 plus DynamoDB. One thing to know: it needs Next.js 16.2 or higher, because that's the version that exposes the caching API it depends on.
Their README is honest about when not to use it: "If Amplify meets your requirements, we recommend you use it." The construct is for when it doesn't, so choose it when you need the extra control.
Watch the cost, though. From their own estimates, the Lambda option lands around 8 USD a month for a small app, while the Fargate options jump past 120 USD, mostly because of an always-on task and a NAT gateway. Pick serverless unless you have a concrete reason to pay more for the extra control
import { App, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { NextjsGlobalFunctions } from "cdk-nextjs";
import { join } from "node:path";
class WebStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
new NextjsGlobalFunctions(this, "Nextjs", {
buildDirectory: join(import.meta.dirname),
healthCheckPath: "/api/health",
});
}
}
const app = new App();
new WebStack(app, "web-stack");My old cdk-hugo-pipeline
If you specifically want the self-contained monorepo, the self-mutating pipeline and the dev/prod stages with basic auth, all for a Hugo site, that's precisely what I wrapped up after the 2023 project into cdk-hugo-pipeline. There's a projen template, so you get going with a single command:
npx projen new \
--from @mavogel/projen-cdk-hugo-pipeline@~0 \
--domain your-domain.com \
--projenrc-tsWould I still build it that way today? For a fresh project, no. I'd reach for Amplify first. But if your goal is to learn cdk pipelines, and mine was, building it yourself is still worth it.
Building the Hugo site itself
One part hasn't changed much: the Hugo site itself. Two things I'd do again.
First, don't copy and paste a theme into your repo. Use Hugo modules, or add the theme as a git submodule so the pipeline can pull it with the same git command. You can browse the full list at themes.gohugo.io.
Second, if you'd rather not tie yourself to S3 at all, a multi-stage Docker build is a clean option: stage one runs Hugo to generate the HTML, stage two drops it into an nginx image. You get a portable container you can run on Fargate or anywhere else. The trade-off is that you now run and maintain a container, so you're paying a monthly bill to buy yourself out of vendor lock-in.
Conclusion
So which one should you pick? Here's my rule of thumb as a table:
| If you want... | Use |
|---|---|
| A static blog live in a few clicks | Amplify Hosting (console) |
| A static site as IaC, no pipeline | aws-solutions-constructs aws-s3-static-website |
| A Next.js app, managed, least effort | Amplify Hosting (WEB_COMPUTE) |
| A Next.js app with every feature and full control | cdk-nextjs |
A self-contained Hugo pipeline, to learn cdk | cdk-hugo-pipeline |
| Zero vendor lock-in, portable | multi-stage Docker on Fargate |
Me personally: this site runs on Amplify WEB_COMPUTE, defined in cdk, managing secrets handled by sops. The 2023 version taught me how CloudFront, WAF, and pipelines actually fit together, and that was worth every hour. But for a new project, I don't rebuild all of that anymore. The managed path finally gives me the result I want with less work.
I hope this helps you pick the right setup for your next site. Happy shipping!



