Back to blog
FILE 0x24·ONE LAMBDA SERVING A RÉSUMÉ AND A BLOG, ROUTED BY HOST HEADE

One Lambda serving a résumé and a blog, routed by Host header

April 16, 2026 · aws, lambda, static-site

I had a static résumé at one subdomain and wanted a blog at another. Two Lambdas felt absurd for what is functionally two HTML generators. So I put both behind a single Lambda that dispatches on the Host header. Total runtime cost: roughly zero.

What was happening

The résumé was already a one-page HTML file embedded in a Lambda — easy to update with a redeploy, no DynamoDB needed. The blog needed actual storage because I wanted to publish posts without redeploying, and I wanted markdown rendering at request time.

Two separate Lambdas would have meant two API Gateway custom domains, two CloudFront distributions, two deployment scripts. The Host header is right there in the event. Why not.

What I found

API Gateway HTTP API supports multiple custom domain mappings against the same backend. Point both cv.example.com and blog.example.com at the same Lambda. The event arrives with the original Host header preserved, and you route on it:

def lambda_handler(event, context):
    host = event["headers"].get("host", "").lower()
    path = event["rawPath"]
    if host.startswith("cv."):
        return render_resume(path)
    if host.startswith("blog."):
        return render_blog(path)
    return {"statusCode": 404, "body": "not found"}

The résumé side is a single string embedded in the source. Editing the résumé is editing a Python dict and redeploying:

RESUME = {
    "name": "...",
    "experience": [
        {"role": "...", "company": "...", "dates": "...", "bullets": [...]},
    ],
}

def render_resume(path):
    if path == "/":
        return html_response(resume_html(RESUME))
    if path == "/print":
        return html_response(resume_print_html(RESUME))
    return {"statusCode": 404, "body": "not found"}

The blog side reads from DynamoDB. Single table, partition key slug, on-demand pricing:

The markdown parser is intentionally tiny. I don't need every feature of CommonMark; I need headings, code fences, lists, blockquotes, bold/italic, and links:

import re

def md_to_html(text):
    out = []
    in_code = False
    code_lang = None
    for line in text.splitlines():
        if line.startswith("```"):
            if in_code:
                out.append("</code></pre>"); in_code = False
            else:
                code_lang = line[3:].strip() or ""
                out.append(f'<pre><code class="lang-{code_lang}">')
                in_code = True
            continue
        if in_code:
            out.append(html_escape(line))
            continue
        if m := re.match(r'^(#{2,3})\s+(.+)$', line):
            level = len(m.group(1))
            out.append(f'<h{level}>{inline(m.group(2))}</h{level}>')
            continue
        if line.startswith("- "):
            out.append(f"<li>{inline(line[2:])}</li>")
            continue
        if line.startswith("> "):
            out.append(f"<blockquote>{inline(line[2:])}</blockquote>")
            continue
        if line.strip():
            out.append(f"<p>{inline(line)}</p>")
    return "\n".join(out)

def inline(s):
    s = re.sub(r'`([^`]+)`', r'<code>\1</code>', s)
    s = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', s)
    s = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', s)
    s = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', s)
    return s

Probably 80 lines once you handle adjacent <li> rolling up into a single <ul>. Worth writing once and never updating.

Adding a new post is one Python script:

import boto3
t = boto3.resource("dynamodb").Table("blog_posts")
t.put_item(Item={
    "slug": "one-lambda-two-sites",
    "title": "One Lambda serving a résumé and a blog",
    "date": "2026-04-16",
    "summary": "Host-header routing instead of two stacks.",
    "body": open("post.md").read(),
    "published": True,
})

What I'd do differently

I would not put the SSL cert as a SAN across both subdomains in the original deployment script. ACM has no problem with it, but every cert rotation now ties the two sites' lifecycles together. If I split blog.example.com to its own stack later (which I might, if the blog grows) I'd need a new cert with no overlap, then update both DNS records. Separate certs from day one are slightly more work and remove an entire class of coupling.

The other thing: the markdown parser is mine and that's both an asset and a liability. It will never have a CVE. It will also never gain features I might later want, like footnotes or tables, without me writing them. The right answer is probably to keep the tiny parser for the existing posts and add a second renderer flag for posts that want the larger feature set. I haven't needed it yet.