Skip to content

Building a Portfolio with Astro and SST, Two Months In

The first version of this post ended with a roadmap: blog search backed by a Lambda, a view counter in DynamoDB, a newsletter through SES. Stay tuned.

Two and a half months later, none of the three exist. What got built instead was an OIDC trust relationship, a CI gate, a design token migration, and a 404 page. That gap is the most useful thing this post can tell you, so it is the last section — but the correction to the stack list comes first, because the original got it wrong.

What actually runs here

Astro 5.18.1     — pinned exactly, not by preference
astro-sst 3.1.4  — the adapter
SST 4.17.1       — infrastructure as code
CloudFront       — CDN, and the router
Lambda           — SSR for the routes that need it
S3               — everything prerendered
Cloudflare       — DNS only

The earlier version of this post claimed Astro 6 and SST v3. Neither was true when it was published, which is a good argument for generating your stack list from package.json rather than memory.

The exact Astro pin is worth explaining, because it looks like caution and is actually a constraint. astro-sst does not support Astro 7. Upgrading past the pin means the adapter stops working, which means the deployment stops working, so the version is held where it is until the adapter moves. It is also the only direct entry in the current npm audit backlog — a dependency you cannot upgrade is a security decision, not just a version number.

The config is the interesting file

The frontend is ordinary Astro. sst.config.ts is where the decisions live, and it is about thirty lines that each encode something learned:

const domains: Record<string, string> = {
  production: "bbobadilla.dev",
  dev: "dev.bbobadilla.dev",
};
const domainName = domains[$app.stage];

Only the long-lived stages get a domain, and that lookup is load-bearing. sst dev deploys under your own username as a stage name. If every stage mapped to a domain, starting a dev session would provision an ACM certificate and write Cloudflare DNS records — on every startup, for a hostname nobody will visit. Unmapped stages fall through to undefined and stay on their CloudFront URL.

removal: input?.stage === "production" ? "retain" : "remove",

Production keeps its resources if the stack is torn down. Everything else is disposable by default, which is what makes throwaway stages safe to create.

const redirects = $app.stage === "production" ? ["www.bbobadilla.dev"] : [];

www is a redirect, not an alias. SST stands up a second distribution that 301s to the apex with the path preserved. An alias would have been fewer moving parts and would have given search engines two URLs for every page; the canonical tags and robots.txt both assume exactly one indexable host, and this is what keeps that assumption true.

And the one that took production traffic to learn:

permissions: [
  { actions: ["ses:SendEmail"], resources: ["*"] },
],

The contact form is the only backend on the site. That block is what lets its Lambda call SES, and its absence fails at runtime rather than at build. Separate post.

Server mode is a default, not a decision

output: 'server' sounds like it settles the rendering question. It does not — it sets what a route does when nobody says otherwise, and on this site most routes say otherwise:

  • The entire /blog tree is prerendered. Markdown fixed at build time has no business waking a Lambda.
  • /api/contact is genuinely server-rendered, and only works under sst dev or a deployed stage — plain astro dev has no AWS credentials to send with.
  • robots.txt is server-rendered on purpose. It compares the request host against the canonical site and serves Allow: / only there, so every non-production stage returns Disallow: / with no per-stage configuration at all.

That last one is a small trick worth stealing. Staging environments leak into search results constantly, and the usual fix is a config flag that someone eventually forgets to set. Deriving it from the host means a new stage is correctly excluded the moment it exists.

The mixed model has a price, and I paid it in public: prerendered and server-rendered routes land on different origins behind one domain, CloudFront picks between them from the URL path alone, and a prerendered child route can make its server-rendered parent unreachable. That is the post I would most want a past version of me to read.

The glass, since it was the original selling point

The aesthetic held up, and the numbers are still what they were:

.glass {
  background: rgba(255, 255, 255, 0.04);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

Background opacity between 0.02 and 0.08, blur around 20px, a border that is nearly invisible and does most of the work of separating the card from what is behind it.

What did not hold up was the assumption that writing those values into components was fine. The site has a second theme, and it reached three components out of nine, because the other six had hardcoded every colour they used. That one has its own post too.

The three features that did not get built

Blog search, powered by a Lambda. There are five posts. Search across five items is a filter over an array that ships in the page, and building a Lambda for it would have been infrastructure as decoration.

A view counter in DynamoDB. This one needs a table, a write path on every render, and some position on bot traffic — and it produces a number that changes no decision I make about the site. I wanted it because counters are satisfying, which is not a requirement.

A newsletter through SES. Blocked on something real: the account is in the SES sandbox, and leaving it means requesting production access with a described use case and a plan for bounces and complaints. “Someone might subscribe” is not that. The sandbox turned out to be the honest reviewer of this idea.

What replaced them was not more interesting on paper, and mattered considerably more:

  • Deploys authenticate through OIDC instead of stored AWS keys. The full write-up, including the one-character detail that broke it.
  • Every deploy passes a verify job first — install, typecheck, build, with no cloud credentials — and it has already blocked a bad deploy.
  • Production only ships on a manual dispatch. Landing on main is not a release.
  • Actions are pinned to commit SHAs, since these jobs hold an OIDC token and a tag is mutable.
  • A 404 page, and two failed attempts at making it cover every route.

None of that was on the roadmap. All of it came from the site being deployed, which is the only thing that generates this class of work.

The part worth generalising

The roadmap you write on day one is a list of features, because features are what you can imagine from the outside. The roadmap production writes for you is a list of consequences — of decisions already made, mostly ones you made without noticing.

I would still choose Astro and SST. The thing I would change is the ending of the original post: not the wishlist itself, but the confidence that a wishlist was the useful part of shipping something.