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


When I decided to build my personal portfolio at [aldolushkja.it](https://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](https://mermaid.ink/img/Z3JhcGggTFIKICAgIFVzZXIoKFZpc2l0b3IpKSAtLT4gUjUzW1JvdXRlIDUzXQogICAgUjUzIC0tPiBDRltDbG91ZEZyb250XQogICAgQ0YgLS0+IFMzW1MzIEJ1Y2tldF0KICAgIEFDTVtBQ00gQ2VydGlmaWNhdGVdIC0uIGF0dGFjaGVkIC4tPiBDRgoKICAgIHN0eWxlIFVzZXIgZmlsbDojNEE5MEQ5LGNvbG9yOiNmZmYKICAgIHN0eWxlIENGIGZpbGw6I0ZGOTkwMCxjb2xvcjojZmZmCiAgICBzdHlsZSBTMyBmaWxsOiM1NjlBMzEsY29sb3I6I2ZmZgogICAgc3R5bGUgUjUzIGZpbGw6IzhDNEZGRixjb2xvcjojZmZmCiAgICBzdHlsZSBBQ00gZmlsbDojREQzNDRDLGNvbG9yOiNmZmYK)

---

## 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:

```bash
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:

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
const rootDeployment = new s3Deploy.BucketDeployment(this, 'RootDeployment', {
  destinationBucket: rootBucket,
  sources: [frontendAsset],
  distribution: distribution,
  distributionPaths: ['/*'], // automatic cache bust on every deploy
});
```

![Build Pipeline](https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBHSEEgYXMgR2l0SHViIEFjdGlvbnMKICAgIHBhcnRpY2lwYW50IENESyBhcyBBV1MgQ0RLCiAgICBwYXJ0aWNpcGFudCBEb2NrZXIgYXMgRG9ja2VyCiAgICBwYXJ0aWNpcGFudCBTMyBhcyBTMyBCdWNrZXQKICAgIHBhcnRpY2lwYW50IENGIGFzIENsb3VkRnJvbnQKCiAgICBHSEEtPj5DREs6IGNkayBkZXBsb3kKICAgIENESy0+PkRvY2tlcjogU3BpbiB1cCBjb250YWluZXIKICAgIERvY2tlci0+PkRvY2tlcjogbnBtIGluc3RhbGwKICAgIERvY2tlci0+PkRvY2tlcjogbnBtIHJ1biBidWlsZAogICAgRG9ja2VyLT4+Q0RLOiBkaXN0IG91dHB1dAogICAgQ0RLLT4+UzM6IFVwbG9hZCBhc3NldHMKICAgIENESy0+PkNGOiBJbnZhbGlkYXRlIGNhY2hlCiAgICBDRi0tPj5HSEE6IERlcGxveW1lbnQgY29tcGxldGUK)

---

## 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](https://mermaid.ink/img/Z3JhcGggVEQKICAgIFB1c2hbR2l0IFB1c2hdIC0tPnxtYXN0ZXJ8IEdIQV9Qcm9kW0dBOiBjZGstZGVwbG95LnltbF0KICAgIFB1c2ggLS0+fGRldnwgR0hBX0RldltHQTogY2RrLWRlcGxveS1kZXYueW1sXQoKICAgIEdIQV9Qcm9kIC0tPiBCdWlsZF9Qcm9kW0J1aWxkIENESyArIFRlc3RzXQogICAgR0hBX0RldiAtLT4gQnVpbGRfRGV2W0J1aWxkIENESyArIFRlc3RzXQoKICAgIEJ1aWxkX1Byb2QgLS0+IERlcGxveV9Qcm9kW0RlcGxveSB0byBhbGRvbHVzaGtqYS5pdF0KICAgIEJ1aWxkX0RldiAtLT4gRGVwbG95X0RldltEZXBsb3kgdG8gZGV2LmFsZG9sdXNoa2phLml0XQoKICAgIERlcGxveV9Qcm9kIC0tPiBQcm9kW1Byb2R1Y3Rpb25dCiAgICBEZXBsb3lfRGV2IC0tPiBEZXZbU3RhZ2luZ10KCiAgICBzdHlsZSBQcm9kIGZpbGw6IzU2OUEzMSxjb2xvcjojZmZmCiAgICBzdHlsZSBEZXYgZmlsbDojRkY5OTAwLGNvbG9yOiNmZmYK)

**`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`:

```yaml
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.


