← All posts
astro, apps script, firebase, hosting, github actions, static site, workload identity federation, serverless

Astro, Apps Script, and Firebase: how site.noprofits.org is built

The site at site.noprofits.org is a marketing and lead-generation site for a free web-design service aimed at Seattle-area nonprofits. Its source lives in the repository noprofits-web-engineering. The stack has four parts:

  • Astro builds the pages into static HyperText Markup Language (HTML) files in a dist/ directory.
  • Firebase Hosting (Spark/free tier) serves that directory from a content delivery network (CDN).
  • Google Apps Script stands in for a backend: it receives form submissions and analytics beacons and writes them to a Google Sheet.
  • GitHub Actions builds and deploys on every push to main, authenticating to Google Cloud with keyless Workload Identity Federation (WIF).

There is no application server and no database. The Google Sheet is the datastore. This post documents each part and how they connect.

System overview

The site runs in three planes. The static plane is the Astro build served by Firebase Hosting. The backend plane is a single Apps Script web app reachable at one Uniform Resource Locator (URL) ending in /exec, which the browser calls directly for both lead submissions and analytics events. A third, separate Apps Script web app renders a read-only analytics dashboard from the same spreadsheet.

Figure 1. System overview. The browser loads static files from Firebase Hosting and sends data directly to the Apps Script endpoint, which appends rows to the Google Sheet; the dashboard reads that same sheet. The four boxes are the four technologies in this post.

The browser loads static assets — HyperText Markup Language (HTML), Cascading Style Sheets (CSS), and JavaScript (JS) — from the CDN. The same browser sends lead and analytics data to the Apps Script endpoint, which appends rows to the sheet. The dashboard web app reads the sheet by ID and renders charts.

Astro

The dependency list is short. package.json declares exactly two runtime dependencies, astro (^5.5.5) and @astrojs/sitemap (^3.2.1), and is marked "type": "module" and private.

astro.config.mjs configures pure static generation (Block 1):

export default defineConfig({
  site: SITE_URL,              // https://site.noprofits.org
  output: 'static',
  integrations: [sitemap(), formEndpointGuard],
});

Block 1. astro.config.mjs — static output, the sitemap integration, and the custom form-endpoint guard.

output: 'static' means every page is rendered to HTML at build time; there is no Astro runtime in production. The pages are index, 404, privacy, and stats, plus three search-engine-optimization guide pages under guides/. Components (Header, Footer, InquiryForm, GuideFooterCta) and one layout (BaseLayout) assemble them. Fonts are self-hosted woff2.

The build-time form-endpoint guard

formEndpointGuard is an inline integration that hooks astro:build:start and aborts the build if PUBLIC_FORM_ENDPOINT does not begin with https://script.google.com. The purpose is narrow: prevent shipping a site whose lead form points at a placeholder or empty endpoint. The escape hatch for local work is ALLOW_PLACEHOLDER_ENDPOINT=true npm run build.

Content Security Policy generated from the build output

The build script is two steps (Block 2):

astro build && node scripts/gen-csp.mjs

Block 2. The build npm script: the Astro build, then Content Security Policy regeneration from the build output.

Astro inlines small client scripts directly into each page. A strict Content Security Policy (CSP) that allows inline scripts by hash must therefore know the hash of every script that shipped. gen-csp.mjs solves this mechanically: after the build, it walks every HTML file in dist/, computes the Secure Hash Algorithm 256 (SHA-256) digest of each executable inline <script> (skipping src= scripts and application/ld+json), and rewrites the script-src hash allowlist inside firebase.json to match. Because it runs as part of build, the CSP cannot drift when a bundled value — such as the form endpoint constant — changes.

src/data/site.ts is the single source of truth for the site URL, contact details, and the build-time-injected FORM_ENDPOINT and DASH_URL, each paired with a guard (isRealEndpoint, hasDashboard) so the templates can degrade gracefully when a value is unset.

Firebase Hosting

firebase.json serves the dist/ directory and defines no rewrites or redirects; routing is the plain trailing-slash structure Astro emits. .firebaserc pins the default project to noprofits-web (Block 3).

{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "headers": [ /* cache + security headers */ ]
  }
}

Block 3. firebase.json (abridged) — the static public directory, the ignore list, and the headers block expanded below.

The headers fall into two groups. Cache headers are tuned per asset class (Table 1):

Path Cache-Control
/_astro/** max-age=31536000, immutable
/fonts/** max-age=604800, stale-while-revalidate
images max-age=3600, must-revalidate
**/*.html max-age=0, must-revalidate

Table 1. Cache-Control headers by asset class, from the headers block of firebase.json.

Hashed Astro assets are cached for a year; HTML is never cached, so a deploy is visible immediately.

Security headers apply to all paths: HTTP Strict Transport Security (HSTS) with preload, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, a Permissions-Policy that disables camera, microphone, geolocation, and interest-cohort, and the CSP described above. The CSP connect-src and form-action directives allowlist exactly https://script.google.com and https://script.googleusercontent.com — the two origins the Apps Script endpoint resolves to.

Google Apps Script

There are two scripts under apps-scripts/, each with its own .clasp.json (for clasp push) and appsscript.json (V8 runtime, America/Los_Angeles timezone).

The first, leads_sheet/, is bound to the spreadsheet. Its Code.js exposes one doPost function that multiplexes three jobs by inspecting the request body. This is the central design decision of the backend: a single deployed endpoint does lead intake, analytics ingest, and a token-gated analytics read.

Figure 2. The single /exec endpoint. One doPost function routes a request to one of three jobs by inspecting the request body, then writes to the matching spreadsheet tab.

The branch is selected as follows: a token field (checked against the DASH_TOKEN script property, fail-closed) returns aggregated analytics as JavaScript Object Notation (JSON); a t parameter routes to logEvent_, which appends to the Events tab; anything else is treated as a lead.

The lead path enforces several server-side abuse controls before it writes a row:

  • a honeypot field (np_hp),
  • a per-minute rate limit via CacheService (RATE_LIMIT_PER_MIN = 12),
  • per-field length caps and a server-side email regex,
  • formula-injection neutralizationneutralize_ prefixes any leading = + - @ with an apostrophe so a submitted value cannot become a live spreadsheet formula,
  • a daily email cap (DAILY_EMAIL_CAP = 60).

Under throttling or the email cap, the lead is still written to the sheet; only the notification email to the operator is suppressed. Leads are never dropped under load. A companion file, Outreach.js, adds an onOpen custom menu to the sheet for sending canned acknowledgement emails manually — deliberately not automated, to avoid turning the deployment into an open relay.

The second script, analytics-dashboard/, is a standalone web app. Its doGet opens the same spreadsheet by ID (SHEET_ID script property), aggregates the Events tab behind a ~2-minute CacheService window, and server-renders dependency-free Scalable Vector Graphics (SVG) charts. It is kept separate from the lead endpoint specifically so its access can be gated to “anyone with a Google account” while the lead and analytics endpoint stays anonymous.

The analytics client (src/lib/analytics.ts) is cookieless and stores no personally identifiable information (PII). It keeps a random per-tab session identifier in sessionStorage, records only the referrer host, and sends events with fetch(..., { mode: 'no-cors', keepalive: true }). If the endpoint is unset, every analytics call is a no-op.

GitHub deployment

The workflow .github/workflows/deploy.yml (“Deploy to Firebase Hosting”) runs on push to main and on workflow_dispatch. It requests contents: read and id-token: write, uses a deploy-hosting concurrency group with cancel-in-progress, and guards the job with if: github.ref == 'refs/heads/main'.

Figure 3. The deploy pipeline. Each push to main runs left to right: checkout, install, build, authenticate with Workload Identity Federation (WIF), and deploy to Firebase Hosting. The authentication step is keyless.

The steps are: actions/checkout, actions/setup-node (Node 20 with npm cache), npm ci, npm run build, a global install of firebase-tools@15.22.0, google-github-actions/auth (Workload Identity Federation), and finally firebase deploy --only hosting --project noprofits-web --non-interactive.

Three details are worth naming:

  1. Keyless authentication. There is no service-account JSON key in the repository. The deploy step obtains short-lived credentials through Workload Identity Federation, whose pool binds the credential to this repository and to assertion.ref == 'refs/heads/main'. That binding, not a stored secret, is the authoritative half of the trust.
  2. firebase-tools is installed before the auth step. The credential-exporting auth step runs after the tool install on purpose, so a compromised firebase-tools release cannot execute with Application Default Credentials already in scope.
  3. Actions are pinned to commit hashes, with version comments, and bumped by Dependabot (weekly, separate ci and deps update groups).

The values the workflow needs — GCP_WIF_PROVIDER, GCP_DEPLOY_SA, PUBLIC_FORM_ENDPOINT, PUBLIC_DASH_URL — are stored as repository Variables, not Secrets. They are identifiers, not credentials: the form endpoint, for instance, ships in the client bundle and is therefore not secret. The security of the deploy rests entirely on the WIF binding described above.

End to end

Figure 4 traces the full path from an edit to a served page.

Figure 4. End to end. A push builds and deploys through GitHub Actions to Firebase Hosting; visitors are served from there and talk to Apps Script directly.

A commit to main triggers the Actions workflow, which builds the Astro site, regenerates the CSP from the build output, and deploys the static files to Firebase Hosting over a keyless connection. Visitors are served those files from the CDN. When a visitor submits the inquiry form or generates an analytics event, the browser talks directly to the Apps Script endpoint, which writes to the Google Sheet. No part of the running site is a server the project operates; the only code that executes on request is in Apps Script, and the only persistent state is a spreadsheet.

Glossary

  • Apps Script (Google Apps Script) — a hosted JavaScript runtime from Google for automating Workspace products. Here it serves as the backend, exposing web endpoints (doPost, doGet) that read and write a Google Sheet.
  • Application Default Credentials (ADC) — the credentials a Google client library picks up automatically from its environment. The deploy avoids exposing these to untrusted code by installing tooling before authenticating.
  • Astro — a static-site framework. Configured here with output: 'static', it renders every page to HTML at build time with no server runtime.
  • CDN (content delivery network) — a geographically distributed cache that serves static files from a location near the visitor. Firebase Hosting provides one.
  • clasp — Google’s command-line tool for pushing local Apps Script source to a script project, configured per script via .clasp.json.
  • CSP (Content Security Policy) — an HTTP response header that restricts where a page may load scripts, connect, and submit forms from. Generated here from the build output by gen-csp.mjs.
  • CSS (Cascading Style Sheets) — the language that styles HTML pages.
  • Dependabot — GitHub’s automated dependency-update bot, configured weekly for the GitHub Actions and npm ecosystems.
  • Firebase Hosting — Google’s static-asset hosting product, served over a CDN. Used on the free Spark plan.
  • honeypot — a hidden form field (np_hp) that real users leave empty; automated submissions that fill it are rejected.
  • HSTS (HTTP Strict Transport Security) — a response header instructing browsers to use HTTPS only. Sent with preload.
  • HTML (HyperText Markup Language) — the markup language of web pages; Astro’s build output.
  • JS (JavaScript) — the programming language that runs in the browser and, via Apps Script, on the backend.
  • JSON (JavaScript Object Notation) — a text data format. The dashboard read returns aggregated analytics as JSON.
  • PII (personally identifiable information) — data that identifies a person. The analytics deliberately store none.
  • SHA-256 (Secure Hash Algorithm, 256-bit) — a cryptographic hash function. gen-csp.mjs hashes each inline script and allowlists the digest in the CSP.
  • Spark plan — Firebase’s free, no-cost service tier.
  • SSG (static site generation) — rendering a site to fixed files at build time rather than per request. Astro’s mode here.
  • SVG (Scalable Vector Graphics) — a vector image format. The dashboard renders charts directly as SVG.
  • URL (Uniform Resource Locator) — a web address. The Apps Script endpoint is a single URL ending in /exec.
  • V8 — the JavaScript engine; the modern Apps Script runtime.
  • WIF (Workload Identity Federation) — a Google Cloud mechanism that lets an external system (here, GitHub Actions) obtain short-lived credentials by proving its identity, with no stored service-account key.
  • woff2 — a compressed web font format; the site self-hosts its fonts in it.
← All posts