Host Your Personal Portfolio on AWS for Almost Nothing — S3, CloudFront & CDK Guide

When I decided to build my personal portfolio at aldolushkja.it, I had two non-negotiable requirements: full infrastructure control and near-zero running costs. No shared hosting, no Netlify/Vercel lock-in — just AWS primitives managed as code. This post walks through the architecture I ended up with and why each choice makes sense cost-wise.
The Stack at a Glance
| Layer | AWS Service | Why |
|---|---|---|
| Storage | S3 | Pennies per GB, no compute |
| CDN + TLS termination | CloudFront | Generous free tier, global edge |
| SSL Certificate | ACM | Free |
| DNS | Route 53 | $0.50/month per hosted zone |
| IaC | AWS CDK (TypeScript) | Repeatable, testable, version-controlled |
| CI/CD | GitHub Actions | Free for public repos |
No EC2 instances. No always-on compute. Nothing to patch or resize.
Infrastructure as Code with AWS CDK
Everything lives in a single CDK stack (CdkStack) written in TypeScript. The whole deployment is reproducible from a clean AWS account with one command:
cdk deploy --all --require-approval never
The stack is parameterized via environment variables, which is what allows the same code to power both production and a staging environment:
const domainName = process.env.DOMAIN_NAME || 'aldolushkja.it';
const certificateArn = process.env.CERTIFICATE_ARN;
new CdkStack(app, 'RootPortfolioStack', {
env: { account: CDK_DEFAULT_ACCOUNT, region: CDK_DEFAULT_REGION },
domainName,
certificateArn,
});
S3 — Static File Hosting
Two S3 buckets handle the two domain variants:
aldolushkja.it— root domainwww.aldolushkja.it— www subdomain
Both serve index.html as the index and error document (which is what makes SPA routing work without a server). Bucket versioning is enabled so rollbacks are trivial if a broken deploy goes out.
const rootBucket = new s3.Bucket(this, 'RootBucket', {
bucketName: props.domainName,
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'index.html',
versioned: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
Cost: a personal portfolio gets maybe a few thousand requests per month and stores well under 1 MB of HTML/CSS/JS. At S3 pricing this is effectively free — we're talking fractions of a cent.
CloudFront — CDN, HTTPS, and Caching
CloudFront sits in front of S3 and handles three things:
- HTTPS termination — the origin is plain S3, CloudFront adds TLS at the edge.
- Global caching — assets are cached at 400+ edge locations, so visitors anywhere in the world get sub-50ms loads.
- Custom domain aliases — both
aldolushkja.itandwww.aldolushkja.itresolve to the same distribution.
const distribution = new cloudfront.CloudFrontWebDistribution(this, 'SiteDistribution', {
originConfigs: [{
s3OriginSource: { s3BucketSource: rootBucket },
behaviors: [{ isDefaultBehavior: true }],
}],
viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(certificate, {
aliases: [props.domainName, `www.${props.domainName}`],
securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
sslMethod: cloudfront.SSLMethod.SNI,
}),
});
Cost: CloudFront's free tier covers 1 TB of data transfer and 10 million HTTP requests per month. A personal portfolio will never get close to that.
ACM — Free SSL Certificates
ACM certificates are completely free. The only constraint is that certificates used with CloudFront must be provisioned in us-east-1, regardless of where the rest of your infrastructure lives.
The stack handles this in two ways:
- If you pass in a pre-existing certificate ARN via env var → it uses that (useful if you already have a wildcard cert).
- Otherwise → it creates a
DnsValidatedCertificatethat auto-validates via Route 53 DNS records.
if (props.certificateArn) {
certificate = acm.Certificate.fromCertificateArn(this, 'Certificate', props.certificateArn);
} else {
certificate = new acm.DnsValidatedCertificate(this, 'SiteCertificate', {
domainName: props.domainName,
subjectAlternativeNames: [`www.${props.domainName}`],
hostedZone: zone,
region: 'us-east-1',
});
}
Route 53 — DNS Management
Route 53 manages the hosted zone for aldolushkja.it. Two A records (alias records) point both the root and www domains to the CloudFront distribution:
new route53.ARecord(this, 'SiteAliasRecord', {
recordName: props.domainName,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
zone,
});
new route53.ARecord(this, 'WWWSiteAliasRecord', {
recordName: `www.${props.domainName}`,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
zone,
});
Alias records to CloudFront are free — AWS doesn't charge for queries to alias records that resolve to AWS resources. The only cost is the hosted zone itself: $0.50/month.
The Build Pipeline — Docker in CDK
The frontend build happens inside CDK's bundling step using a Docker container. This means the CI runner doesn't need Node.js pre-installed — CDK pulls a node:22 image and builds inside it:
const frontendAsset = s3Deploy.Source.asset(frontendPath, {
bundling: {
image: cdk.DockerImage.fromRegistry('node:22'),
environment: {
COMMIT_SHA: commitSha, // injected into index.html at build time
},
command: [
'bash', '-c',
'npm install && npm run build:css && npm run build && cp -r dist/* /asset-output',
],
user: 'root',
},
});
After deployment, CloudFront's cache is automatically invalidated for /* — so visitors always get the latest version without stale edge caches:
const rootDeployment = new s3Deploy.BucketDeployment(this, 'RootDeployment', {
destinationBucket: rootBucket,
sources: [frontendAsset],
distribution: distribution,
distributionPaths: ['/*'], // automatic cache bust on every deploy
});
Two Environments, One Codebase
I maintain two environments: production (aldolushkja.it) and staging (dev.aldolushkja.it). Both are driven by the same CDK stack — the only difference is the DOMAIN_NAME environment variable.
cdk-deploy.yml triggers on push to master — no DOMAIN_NAME set, so it defaults to aldolushkja.it.
cdk-deploy-dev.yml triggers on push to dev:
env:
DOMAIN_NAME: dev.aldolushkja.it
This means I can test infrastructure changes on the dev subdomain before they hit production — without any extra tooling or duplication.
What's the Actual Monthly Bill?
For a personal portfolio with moderate traffic:
| Service | Cost |
|---|---|
| S3 (storage + requests) | ~$0.01 |
| CloudFront | $0.00 (free tier) |
| ACM | $0.00 |
| Route 53 (hosted zone) | $0.50 |
| Total | ~$0.51/month |
That's it. The only real cost is the Route 53 hosted zone. Everything else fits comfortably within free tiers or rounds to zero at personal-site traffic levels.
What I'd Add Next
The repo already has a BackendStack defined (but not deployed) for a Lambda + API Gateway contact form. When I activate it, Lambda's free tier (1M requests/month) means it'll still cost essentially nothing.
Takeaways
- Static sites on S3 + CloudFront are the most cost-efficient way to host anything that doesn't need a server. The combination gives you global CDN, HTTPS, and high availability for pocket change.
- AWS CDK makes this reproducible and testable. The entire infrastructure is TypeScript, sits in the same repo as the frontend, and has unit tests.
- Parameterizing the stack for multiple environments costs nothing extra — it's just the same S3/CloudFront/Route53 setup pointed at a different domain.

