Skip to content

← Archive

Building a Portfolio That Practices What It Preaches

How I deployed an Angular 21 SSR app on AWS Lambda, and why every architectural decision was really a statement about how I think software should be built.


13 March 20268 min read

Written in March 2026, when this site ran as Angular SSR on Lambda. On 27 August I moved it to static HTML prerendered at build time, as the colophon describes, so the architecture below is how the site used to work. The Host header bug and its fix still apply to anyone serving Angular SSR through CloudFront from a Lambda function URL.

Most developer portfolios are static sites, and there's nothing wrong with that. If all you need is a page that says "here's my work", a static site does the job. I wanted this site to be the work itself, so every decision in the stack is deliberate, and together they reflect how I think about building software.

This isn't really a deployment guide. It's about the principles behind the decisions, and about a particularly stubborn bug that tested all of them.

Start with the Constraint, Not the Tool§

The first question wasn't which framework to use. It was what the constraints were.

I wanted server-side rendering, for fast first loads and proper SEO. I wanted infrastructure I wouldn't have to babysit. I wanted zero ongoing cost when nobody's visiting. And I wanted deployment to be simple enough that I could push code and walk away.

Define the constraints clearly and the architecture almost designs itself. SSR means a server. Zero cost at idle means serverless. Push-and-forget means a CI/CD pipeline. Angular, Lambda, CDK and CloudFront are just the implementations, and they could be swapped out without the principles changing.

This is something I've learned working on production systems in my day job. Start with the problem, not the technology. The teams that pick tools first and then try to fit their problem into them always end up fighting the architecture later.

The Architecture§

Request flow diagram
Request flow diagram

The system has two paths for serving content. Static assets, meaning JavaScript bundles, CSS, images and fonts, are served directly from S3 via CloudFront. Everything else hits a Lambda function running the Angular SSR server.

That separation matters. Static assets carry content hashes in their filenames. They're immutable once deployed and cached for a year. The SSR responses are dynamic because they render the page on every request, so the HTML always reflects the latest build. CloudFront sits in front of both and handles HTTPS termination and edge caching.

It's separation of concerns applied to infrastructure. The same principle that says don't put business logic in your controller also says don't serve static files through your application server, and each component ends up doing one thing well.

Lambda Web Adapter is what makes the serverless SSR work. It's an AWS-provided layer that wraps any HTTP server, Express or Fastify or whatever else, and handles the Lambda invocation lifecycle. From the application's perspective it's an ordinary Express server listening on port 8080. The adapter does the translation between Lambda's event model and HTTP. The application code doesn't know or care that it's running on Lambda. Good abstractions look like that.

The Pipeline: Systems Should Maintain Themselves§

Deployment pipeline diagram
Deployment pipeline diagram

The deployment pipeline is self-mutating. Change the pipeline definition itself, by adding a build step or modifying the deployment order, and it updates itself before deploying the application. The only manual cdk deploy I've ever run was the initial bootstrap.

This is a principle I care about deeply: a system should be capable of maintaining itself. If deploying a change to your deployment process requires a manual process, you've created a recursive problem. CDK Pipelines solves it neatly, because the pipeline is just another piece of infrastructure defined in code.

The pipeline watches two repositories: the infrastructure repo with the CDK stacks, and the web app repo with the Angular application. A push to either triggers a full build and deploy. The synth step builds the Angular app, generates the blog content from markdown, synthesises the CloudFormation templates, and the pipeline takes it from there.

There's a broader point about infrastructure as code that goes beyond version control. When your infrastructure is code it's reviewable and testable and reproducible, and if I deleted every AWS resource tomorrow a single cdk deploy would recreate the entire stack identically. That's more than convenient. The infrastructure is documented by its own existence. No wiki page three months out of date describing what's deployed where.

Domain-Driven Thinking Beyond the Backend§

The project structure follows domain-driven design principles even though it's a frontend application. The codebase is organised around business concepts, so features/hero, features/experience and features/skills, rather than around technical layers like components/, services/ or pages/.

That matters more than it might seem. When I need to change how the experience section works I go to features/experience/ and everything I need is there, instead of hunting across five different folders for the component, its service, its model and its tests. The code is organised around what it does, not what it is.

The same principle applies to the infrastructure, where each CDK stack has a single responsibility. DnsStack manages the hosted zone, CertificateStack handles TLS, and WebStack composes the application layer. They depend on each other explicitly through typed props rather than through hardcoded ARNs or naming conventions.

This is how I structure all the systems I work on. The domain drives the architecture and the architecture gives you boundaries. When the requirements change, as they always do, the boundaries tell you where.

The Bug That Tested Everything§

After the first successful deployment, the site returned a 400 Bad Request:

URL with hostname "xxx.lambda-url.eu-west-2.on.aws" is not allowed.

Angular 21.2.2 had introduced SSRF protection as part of a CVE fix. The AngularNodeAppEngine validates the Host header against an allowlist, and the Lambda function URL hostname wasn't on it.

This is where debugging philosophy matters. The temptation with a cryptic error is to start changing things at random, adding an environment variable here, trying a different config format there, and I've watched teams burn hours that way. The disciplined approach is to understand the system before you try to fix it.

So I traced the request path. CloudFront receives the request with Host: davidshortland.dev, forwards it to the Lambda function URL, and replaces the Host header with the Lambda URL hostname, which is standard CloudFront behaviour for function URL origins. Lambda Web Adapter passes that to Express and Express passes it to Angular's SSR engine. The engine checks the Host header against its allowlist, doesn't find the Lambda hostname, and returns a 400.

Once you understand the flow the fix is obvious: tell Angular about the Lambda hostname. The implementation of that fix had its own subtlety, and I tried three approaches that didn't work.

Setting NG_ALLOWED_HOSTS as a Lambda environment variable. This seemed like the right approach because it's documented. Angular 21.2.2 reads it at build time and bakes it into the SSR manifest, so a runtime environment variable arrives too late.

Passing allowedHosts in the AngularNodeAppEngine constructor. The API accepts it and the build-time manifest takes precedence. The constructor options are additive rather than overriding, and the manifest was empty.

Using dot-prefix patterns in angular.json. Close, and incorrect syntax. Angular uses *.example.com wildcard notation rather than .example.com.

The fix was the allowedHosts array in angular.json under security, using wildcard patterns:

{
  "security": {
    "allowedHosts": [
      "localhost",
      "davidshortland.dev",
      "www.davidshortland.dev",
      "*.lambda-url.eu-west-2.on.aws",
      "*.cloudfront.net"
    ]
  }
}

The key thing is that this configuration is baked into the build output. It is not a runtime setting. Every failed attempt required a full pipeline cycle to test, so push, build, deploy, check, which is where the self-mutating pipeline earned its keep, since at least I didn't have to deploy each attempt by hand.

The lesson has nothing to do with Angular configuration. It's about tracing a problem through the entire system before reaching for solutions.

The Cold Start Tradeoff§

Lambda functions have cold starts. The first request after a period of inactivity takes longer because AWS needs to initialise the runtime, and for this site a cold start adds roughly 2 to 3 seconds to that first request.

I'm comfortable with that tradeoff. It comes down to the common case.

The common case for a portfolio site is that nobody is visiting. I'd rather pay zero pounds during those idle hours and accept a slightly slower first load than run a t3.micro 24/7 for instant responses to traffic that doesn't exist. Once the function is warm, subsequent requests are fast, typically 100 to 200 ms for a full SSR render.

If this were a high-traffic application the calculus would be different, and provisioned concurrency or a container-based deployment would make more sense. Applying high-traffic patterns to a low-traffic site is over-engineering. You're spending complexity on a problem you don't have.

Which connects to a broader principle, that every architectural decision has a context. There's no universally correct answer to "should I use serverless?" The answer is always conditional on what you're building, for whom, and under what constraints. Developers who insist that one approach is always right are usually the ones who haven't worked across enough different problems.

Iterative Delivery Over Big Bang Releases§

The site wasn't built in one go. It was deployed to production within hours of starting, initially as a working SSR page with the basic structure, and features were added incrementally: the telemetry gauge animations, the blog system, security headers, analytics. Each change was a small, deployable unit.

That's agile as a mindset, and the pipeline is what enables it, because pushing a small change to production takes minutes rather than hours. When the cost of deployment is near zero you naturally gravitate toward smaller, more frequent changes. When deployment is painful you batch changes together, which increases risk and makes debugging harder.

The Stack§

For anyone building something similar:

  • Angular 21 with @angular/ssr and outputMode: server
  • Express 5 via AngularNodeAppEngine
  • Lambda Web Adapter layer (ARM64), which wraps Express as a Lambda function
  • CloudFront with dual origins: S3 for static assets, Lambda Function URL for SSR
  • CDK with a self-mutating CodePipeline watching two repositories
  • TailwindCSS v4 via @tailwindcss/postcss (Angular's built-in support doesn't fully handle v4 syntax)
  • Blog system built from markdown files processed at build time into bundled JSON

The total infrastructure cost for a low-traffic site is effectively zero, comfortably within the AWS free tier.

More important than the specific tools is understanding why you're choosing each one. If you can't articulate the principle behind a decision, you probably haven't made the decision yet; you've defaulted to something familiar, and familiar isn't always the right choice.