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 neutralization —
neutralize_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:
- 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. firebase-toolsis installed before the auth step. The credential-exporting auth step runs after the tool install on purpose, so a compromisedfirebase-toolsrelease cannot execute with Application Default Credentials already in scope.- Actions are pinned to commit hashes, with version comments, and bumped by
Dependabot (weekly, separate
cianddepsupdate 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.mjshashes 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.