DEV Community

takahiro hashito
takahiro hashito

Posted on

Serving a static site from a Cloud Functions seed bundle

Background

I run a handful of small static sites as a side project. All but one work the same way: a build script emits HTML, and firebase deploy --only hosting ships it.

One site is different. Its articles live in Firestore, and the HTML is assembled by a Cloud Function that pushes pages through the Firebase Hosting REST API. This post explains why I made that exception and what it cost me.

Some terms up front, since I will use them throughout:

  • Firebase Hosting — a CDN (Content Delivery Network: copies of your files are kept on servers around the world so visitors are served from a nearby one). Normally you upload a local directory to it.
  • Cloud Functions — serverless functions on Google Cloud. Two of them here are plain HTTP endpoints.
  • Firestore — a document database. One article equals one document.
  • seed bundle — when you deploy a Cloud Function, everything under functions/ is packed into the runtime. The article Markdown files ride along inside that pack.

How it works

The publish path has three stages, shown below. The Markdown files on the left are the ones I actually edit; everything to the right of them is derived.

functions/seed/articles/<slug>.md   (Markdown, in the repo)
        |
        |  (1) firebase deploy --only functions
        v
seed/articles/ inside the deployed function bundle
        |
        |  (2) HTTP: setupInitialData
        v
     Firestore "articles" collection
        |
        |  (3) HTTP: redeployHosting
        v
All pages rebuilt and shipped via the Hosting REST API
Enter fullscreen mode Exit fullscreen mode

Nothing in that diagram is firebase deploy --only hosting. On this site that command is not merely useless — it would overwrite custom-delivered production with the contents of the local dist directory.

Step (1) is the one that surprises people. The import function reads Markdown like this:

const articlesDir = path.join(__dirname, "seed", "articles");
Enter fullscreen mode Exit fullscreen mode

__dirname points inside the deployed runtime, not at your laptop. So the set of articles the function can see is frozen at the moment of the last firebase deploy --only functions. Dropping a new .md into your local checkout changes nothing on the function's side.

That is the central consequence of this design: because the article source lives inside a deployed artifact, every new article requires a function redeploy first.

Implementation

Why put Firestore in the middle at all? Because I want to rebuild every page at once.

Footer cross-links, structured data, breadcrumbs, OGP (Open Graph Protocol: the meta tags that decide the title, description, and image shown when a link is shared on social media) — none of these mean anything unless they are consistent site-wide. If each article owns a frozen HTML file, changing a shared fragment means regenerating everything, and you still have to decide where that regeneration happens.

With the articles in Firestore, redeployHosting reads "all articles as of now" and rebuilds every page from that. No local build output is involved.

There is a second trap. Cloud Functions only bundles what is under functions/. A shared config sitting at the repo root will require fine locally and throw MODULE_NOT_FOUND in production. I sync it in a predeploy hook:

"functions": {
  "source": "functions",
  "runtime": "nodejs22",
  "predeploy": [
    "node tools/copy-fleet-sites.js"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Static assets follow the same logic and are listed explicitly:

const STATIC_ASSETS = {
  "og-image.png": "image/png",
  "og-image.svg": "image/svg+xml",
  "favicon.svg": "image/svg+xml",
  "apple-touch-icon.png.svg": "image/svg+xml",
};
Enter fullscreen mode Exit fullscreen mode

Production consists of the Firestore articles plus exactly this list. A file that is not listed here does not exist in production. Before I wrote that map, og:image returned 404 on every page — the file was sitting in the local directory that custom delivery never reads.

Gotchas

The worst one was duplicate slugs.

The slug is the Firestore document ID. Two articles with the same slug means the second import overwrites the first, and the overwritten article loses its public URL and starts returning 404.

What makes it nasty is that the build cannot catch it. The static site generator reads src/content/blog; the delivery path reads functions/seed/articles. The build stays green while production quietly loses an article.

So I put the check at the entrance to delivery, not in the build:

node build-and-deploy.js --seed
  → npm run test:slugs        (duplicates stop everything here)
  → firebase deploy --only functions
  → setupInitialData
Enter fullscreen mode Exit fullscreen mode

Guarding the build would have been pointless: you can skip the build and call the import directly, and any check that can be bypassed will be.

The reverse problem exists too. Deleting a Markdown file does not delete the Firestore document, so renamed or merged articles leave orphans serving stale URLs. The import reports slugs that exist in Firestore but not in the seed; deletion only happens when explicitly requested. One line guards it:

if (!seedSlugs.size) return [];  // never treat "could not read the seed" as "every article is an orphan"
Enter fullscreen mode Exit fullscreen mode

If reading the seed fails and you trust the resulting empty set, every article in Firestore looks orphaned. Combine that with the delete flag and the site loses all of its content. A failed read is not a result of zero.

Result

The site running on this setup: https://gadget.autoarticles.net

Takeaway

One thing generalizes from all of this.

When the single source of truth lives inside a deployed artifact rather than on your dev machine, a multi-step publish flow is not bad engineering — it is the direct consequence of that choice.

The steps are not the problem. The problem is papering over them with a runbook that a human has to remember. Trying to shorten the flow by reaching for firebase deploy --only hosting is worse than useless here: it overwrites custom-delivered production with whatever is in your local dist.

The right move is to collapse the steps into a single command and put the checks at its entrance. The bar I hold this to is simple: it should not break when nobody remembers the procedure.


This article is about my own side project. It was written with AI assistance.

Top comments (0)