If you deployed a Next.js or Angular app with firebase deploy and the CLI said something about "web frameworks (experimental)," your server-side rendering has been running inside a Cloud Function this whole time, behind the Hosting CDN. App Hosting is the replacement path: Cloud Build builds your repo on push, the server runs as a Cloud Run service, and Hosting's CDN sits in front of it.
The migration is not a lift-and-shift of a config file. It changes where your code is built, how it is billed, how secrets reach it, and which caching layer is doing the work. Here is what actually moves, and the order we cut over.
What changes, concretely
| Hosting web frameworks | App Hosting | |
|---|---|---|
| Build | Local machine, during firebase deploy | Cloud Build, triggered by a push to a branch |
| Runtime | Cloud Function (SSR function) | Cloud Run service |
| Config | firebase.json | apphosting.yaml in the repo |
| Secrets | Whatever you baked in at build time | Secret Manager, referenced by name |
| Rollback | Redeploy an older build from a laptop | Roll back to a prior rollout |
| Scaling knobs | Function concurrency, memory | runConfig: CPU, memory, concurrency, min/max instances |
The important line is the first one. Under web frameworks, the build artifact came from a developer machine, which means "works on my laptop" was a load-bearing part of your deploy story. App Hosting builds from the repository. Any dependency that was only installed locally, any env var that only existed in someone's shell, any postinstall that needed a private token — all of that surfaces on the first Cloud Build run. Expect to spend your first afternoon on build failures, not on runtime behavior.
The config file
apphosting.yaml lives at the repo root and is read at build and run time:
runConfig:
cpu: 1
memoryMiB: 512
concurrency: 80
minInstances: 0
maxInstances: 10
env:
- variable: NEXT_PUBLIC_API_BASE
value: https://api.example.com
availability:
- BUILD
- RUNTIME
- variable: STRIPE_SECRET_KEY
secret: stripe-secret-key
availability:
- RUNTIME
Two details bite teams:
availability is not optional thinking. A value your bundler inlines (anything NEXT_PUBLIC_*) must be available at BUILD. A server-only credential should be RUNTIME only. Mark a secret BUILD and you risk it being inlined into a client bundle — which is a disclosure, not a misconfiguration.
Secrets are references, not values. secret: stripe-secret-key points at Secret Manager. The App Hosting backend's service account needs roles/secretmanager.secretAccessor on that secret, and firebase apphosting:secrets:grantaccess will wire it. If you forget, the build succeeds and the container fails on boot — the failure shows up as a rollout that never becomes healthy.
The bill changes shape
This is the part worth measuring before you cut over. Cloud Functions billed you per invocation plus GB-seconds. Cloud Run under App Hosting bills CPU and memory time for instances that are up, and a request-based fee — but with request concurrency, so one instance serves many requests at once.
For most SSR apps that is cheaper, sometimes substantially. Take 2,000,000 requests a month, 120 ms of server work each, 1 vCPU / 512 MiB:
- As a function, concurrency 1: 2,000,000 × 0.12 s = 240,000 instance-seconds of compute, plus per-invocation charges. Every request holds a whole instance for its duration.
- On Cloud Run, concurrency 80: the same work packs into far fewer instance-seconds, because one instance overlaps dozens of in-flight renders. Sustained traffic of a few requests per second sits comfortably inside one or two instances.
The number that ruins this arithmetic is minInstances. One always-on instance at 1 vCPU / 512 MiB runs roughly 2.6 million CPU-seconds a month whether anyone visits or not. That is a fixed line on the invoice — often a handful of dollars for a small instance, and worth it if your p95 cold start is hurting — but it is a decision to make with numbers, not a default to copy off a blog. Set it to 0 first, measure cold starts against real traffic, and raise it only for the backend serving your logged-in paths.
Second cost trap: CDN caching goes quiet if you stop setting cache headers. Under web frameworks, framework adapters set Cache-Control for you on static and ISR-ish routes. On App Hosting the CDN still sits in front, but it honors your headers. An SSR route that returns Cache-Control: private, no-store — the default in several frameworks — means every request reaches Cloud Run. We have seen a migration triple compute spend for exactly this reason, with identical traffic. Check a representative set of URLs after cutover:
curl -sI https://your-app.web.app/pricing | grep -iE 'cache-control|x-vercel|age|x-cache'
If public marketing pages are not being served from cache, fix the headers before you tune instances.
Auth, App Check, and the server side
SSR means your server now holds tokens. Three things to get right:
- Session cookies over ID tokens. If the server renders authenticated pages, mint a session cookie with the Admin SDK (
createSessionCookie) and verify it per request (verifySessionCookie(cookie, true)— thetruechecks revocation). Passing raw ID tokens into server components tends to produce stale-token bugs at the one-hour boundary. - Do not cache authenticated HTML. Any route that varies by user must be
private, no-store. The CDN is shared. This is the one place where losing cache hits is the correct outcome. - App Check is for client SDK traffic. Your server calls Firestore with the Admin SDK, which bypasses rules and App Check entirely. That is fine, and it means server-rendered data access is governed by your code rather than your rules — so the authorization checks that used to live in
firestore.rulesneed an equivalent in the server handler. Auditing that gap is the most common finding we file on SSR migrations.
A cutover order that does not risk the live site
App Hosting backends each get their own URL, so you can run both stacks in parallel. We do it in this order:
- Create a backend on a non-production branch. Point it at
staging, notmain. Get Cloud Build green. This is where the laptop-only dependencies die. - Move secrets to Secret Manager and grant access. Confirm the container boots and a health route responds.
- Compare rendered output. Fetch 20-30 representative URLs from both stacks and diff the HTML. Framework-version differences and missing build-time env vars show up here, cheaply.
- Fix cache headers, then load-test. Send realistic traffic at the App Hosting URL with
minInstances: 0and record cold-start p95 and CDN hit ratio. DecideconcurrencyandminInstancesfrom those numbers. - Point a preview domain at it. Real TLS, real CDN, real cookie domain. Session cookies and
SameSitebehavior often fail only at this step. - Cut the production domain over, keep the old stack deployed. Leave the Hosting function in place for a week. If something regresses, you are one DNS or Hosting config change from back.
- Delete the old SSR function. Not before. An idle Cloud Function costs nothing; an unavailable rollback costs a weekend.
When not to migrate
If your app is fully static — a Next.js export, an Angular prerender, plain Vite output — you do not need App Hosting. Static assets on Hosting are served from the CDN, there is no server to run, and adding a Cloud Run service adds cost and moving parts for nothing.
If your SSR is heavier than a request/response renderer — long-running jobs, websockets, large in-memory caches, GPU work — then App Hosting's opinionated Cloud Run wrapper will fight you, and a Cloud Run service you configure yourself is the better fit. App Hosting is a good default for framework SSR; it is not the only way to run a container.
The honest summary: App Hosting fixes the two real problems with web frameworks on Hosting — builds that depended on a developer laptop, and SSR wedged into a concurrency-1 function. It does not decide your cache policy or your server-side authorization for you, and those two are where the migration bills or bites.