Skip to content
All posts

· 2 min read

From proof-of-concept to production with SvelteKit on Cloud Run

What it took to turn a promising Svelte prototype into a secure, tested planning tool on Google Cloud.

  • SvelteKit
  • GCP
  • Cloud Run
  • Testing

At the Department for Transport I inherited a Svelte proof-of-concept for the Connectivity Planning Tool: a map-heavy app that helps planners explore transport connectivity. The prototype proved the idea. The job was to make it something a department could run, secure and change with confidence. These were the steps that mattered most.

Start with the architecture the prototype skipped

The prototype was a client-only Svelte app. Moving to SvelteKit gave us server-side rendering for fast first paint and a proper server boundary. With that we could keep secrets and service-account credentials off the client, and run authorisation logic before any HTML went out. Client-side hydration then takes over for the interactive map.

Configure like a twelve-factor app

Every environment difference became an environment variable, read through SvelteKit’s $env/dynamic/private so the same container image runs in every environment:

// src/lib/server/config.ts
import { env } from '$env/dynamic/private';

export const config = {
  projectId: env.GCP_PROJECT_ID,
  geoBucket: env.GEOJSON_BUCKET,
};

Built with adapter-node, the app listens on the PORT that Cloud Run injects, so the Dockerfile stays boring.

Let Identity-Aware Proxy do authentication

Rather than building a login flow, we put the service behind Google Identity-Aware Proxy (IAP). IAP authenticates users before requests ever reach the container and forwards their identity in headers. A SvelteKit handle hook turns that into event.locals.user for every route:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const header = event.request.headers.get('x-goog-authenticated-user-email');
  event.locals.user = header ? { email: header.replace('accounts.google.com:', '') } : null;
  return resolve(event);
};

In production, also verify the signed x-goog-iap-jwt-assertion header against Google’s public keys, so a request that somehow bypasses the proxy can’t spoof the email header.

Small, purposeful persistence

User preferences such as saved map views needed a home, but not a database server. Firestore, accessed from the same server hooks with a service account, was the right size: no connection pooling, no migrations, and per-user documents keyed by the IAP identity.

Accessible maps are a design problem

We used MapLibre GL with Svelte for the mapping UI, styled to the GOV.UK Design System, with GeoJSON layers loaded from Cloud Storage. A map can’t be the only way to get information, so every map interaction has a non-visual equivalent: focusable controls, text summaries of the selected area, and data tables a screen reader can navigate.

Tests at three levels, in CI from the start

  • Vitest for pure logic and server utilities
  • Testing Library for Svelte components, queried by role and label, as a user would
  • Playwright for end-to-end journeys against the built app

All three run in GitHub Actions on every pull request. Turning the prototype into production code took far less courage once we had a red/green signal for every change.

Local development that mirrors the cloud

Finally, docker-compose and the official emulators gave every developer a local Firestore and Storage, so nobody needed cloud credentials to work on a feature. I’ve written that setup up separately in mirroring the cloud locally.