Skip to main content

Command Palette

Search for a command to run...

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

Published
6 min read
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.

Architecture Overview


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 domain
  • www.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:

  1. HTTPS termination — the origin is plain S3, CloudFront adds TLS at the edge.
  2. Global caching — assets are cached at 400+ edge locations, so visitors anywhere in the world get sub-50ms loads.
  3. Custom domain aliases — both aldolushkja.it and www.aldolushkja.it resolve 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 DnsValidatedCertificate that 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
});

Build Pipeline


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.

CI/CD Pipeline

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.
8 views
Host Your Personal Portfolio on AWS for Almost Nothing — S3, CloudFront & CDK Guide