# Shipping a full-stack app

> Build and deploy the frontend and API as one artifact: serving the SPA from Hono, caching, one Docker image, migrations, a CI pipeline and safe rolling deploys.

Full-Stack Developer roadmap · Ship it · 18 min read · https://levelcode.org/roadmaps/full-stack/shipping-full-stack

The last step is getting the whole product, frontend and API, to users, and doing it again ten times a day without breaking anything. This lesson ships the `shop` monorepo as **one artifact**: the Node server serves both the API and the built React app from one origin, packaged in one Docker image. It covers cache headers that make the frontend fast without serving stale code, migrations during deploys, a CI pipeline that tests the whole stack, and the compatibility rules that keep rolling deploys safe while old JavaScript is still running in users' browsers.

## One server, one origin

In production, the frontend is just files: `vite build` produces `index.html` and hashed bundles in `apps/web/dist`. The Hono server can serve them alongside the API, which gives you the same-origin setup from the architecture lesson with nothing else to run:

```ts
// apps/api/src/app.ts
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { apiRoutes } from './api.ts'

const WEB_DIST = process.env.WEB_DIST ?? '../web/dist'

export const app = new Hono()
app.route('/api', apiRoutes)

// Unknown /api paths must be JSON 404s, never the SPA's index.html.
app.all('/api/*', (c) => c.json({ error: { code: 'route_not_found' } }, 404))

// Hashed build assets: cache for a year. A missing one is a real 404, not the SPA.
app.use('/assets/*', async (c, next) => {
  await next()
  if (c.res.status === 200) c.header('Cache-Control', 'public, max-age=31536000, immutable')
})
app.use('/assets/*', serveStatic({ root: WEB_DIST }))
app.get('/assets/*', (c) => c.text('Not found', 404))

// Everything else: real files if they exist (favicon.ico, robots.txt), else the SPA shell.
app.use('*', async (c, next) => {
  await next()
  if (c.res.headers.get('Content-Type')?.startsWith('text/html')) c.header('Cache-Control', 'no-cache')
})
app.use('*', serveStatic({ root: WEB_DIST }))
app.get('*', serveStatic({ root: WEB_DIST, path: 'index.html' }))
```

The order matters. API routes come first. Then a catch-all for `/api/*`, so a mistyped API path gets a JSON 404 instead of the HTML page. Then the hashed assets, where a missing file is a genuine 404: answering a request for a `.js` file with `index.html` would hand the browser HTML where it expects JavaScript, and turn a clear error into a confusing one. Last comes the **SPA fallback**: a reload on `/orders/42` has no file on disk, so the server returns `index.html` and React Router renders the right page in the browser. The header middlewares run *around* the static handlers (code after `await next()` sees the finished response), which is how each kind of file gets its own `Cache-Control`.

If you'd rather put a CDN in front, the same rules apply: route `/api/*` to the server, everything else to static hosting with the same fallback and cache headers.

## Cache headers: fast and never stale

Vite names built assets by their content hash: `assets/index-3f9a2c1b.js`. If the content changes, the name changes. That allows two very different policies:

| File | Cache-Control | Why |
|---|---|---|
| `/assets/*` (hashed) | `public, max-age=31536000, immutable` | The file at this URL can never change, so browsers and CDNs keep it for a year and never revalidate |
| `index.html` and the fallback | `no-cache` | Always revalidate, so users get the HTML that points to the newest bundles right after a deploy |

`no-cache` doesn't mean "don't store": the browser stores it but checks with the server (cheaply, with an ETag) before using it. The result: after a deploy, a single small HTML request picks up the new version, and everything else comes from cache. Get this backwards (a long cache on `index.html`) and users run old code for days.

## One image for the whole app

```dockerfile
# syntax=docker/dockerfile:1

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
RUN npm ci
COPY . .
RUN npm run typecheck && npm run build --workspace @shop/web

FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
RUN npm ci --omit=dev --workspace @shop/api --include-workspace-root=false

FROM node:22-slim
ENV NODE_ENV=production WEB_DIST=/app/apps/web/dist
WORKDIR /app/apps/api
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY packages/shared /app/packages/shared
COPY apps/api /app/apps/api
COPY --from=build /app/apps/web/dist /app/apps/web/dist
USER node
EXPOSE 3000
CMD ["node", "--enable-source-maps", "src/server.ts"]
```

Notes on the choices:

- **Copy the `package.json` files before the source.** The `npm ci` layer is then cached until dependencies change, which makes most builds take seconds.
- **The type check runs during the build**, so an image can't be produced from code that doesn't typecheck.
- **The API runs its TypeScript directly** with Node's type stripping, which keeps the shared package (also TypeScript source) working without a separate build step for each package. The Node roadmap's alternative, compiling to `dist/` with `tsc`, works too; pick one approach per repository.
- **The final image** has production dependencies for the API only, the built frontend, and runs as the unprivileged `node` user, started with `node` directly so SIGTERM reaches your graceful shutdown.
- A `.dockerignore` keeps `node_modules`, `dist`, `.env` and `.git` out of the build context.

Frontend and API are built from the same commit and shipped together, so the bundle the server serves always matches the API it talks to. That removes a whole category of version mismatches, but not all of them, as you'll see below.

## Migrations during deploys

The API runs migrations at startup under an advisory lock (from the Node roadmap), so the first new instance applies them. For larger teams, a separate **release step** that runs migrations before any new instance starts is clearer: if a migration fails, the deploy stops with the old version still serving.

Either way, during a rolling deploy **old and new versions of the API run at the same time against the new schema**. So every migration must work with the code that's currently deployed:

- Adding a nullable column or a new table: safe.
- Renaming or dropping a column: not safe in one step. Use **expand and contract**: add the new column, deploy code that writes both and reads the new one, backfill, deploy code that only uses the new one, and only then drop the old column in a later release.

## Old JavaScript in open tabs

Even when frontend and API ship together, users don't reload on your schedule. A tab opened yesterday runs yesterday's bundle against today's API. Two consequences:

**API changes must be backwards compatible.** Add fields freely; don't rename or remove fields, or change their meaning, while old clients may still exist. When a breaking change is unavoidable, add a new endpoint (or version), keep the old one until traffic to it stops, then remove it.

**Lazy-loaded chunks disappear.** The old bundle may try to load `assets/reports-1a2b3c.js` for a route-split page, but the deploy replaced it with a differently hashed file, so the request fails. Handle it by reloading once to pick up the new version:

```ts
// apps/web/src/main.tsx
window.addEventListener('vite:preloadError', (event) => {
  event.preventDefault()
  if (!sessionStorage.getItem('reloaded-for-new-version')) {
    sessionStorage.setItem('reloaded-for-new-version', '1')
    window.location.reload()
  }
})
```

Vite fires `vite:preloadError` when a dynamic import fails. The session-storage flag prevents a reload loop if the chunk is missing for another reason. Keeping the previous release's assets available for a while (as many static hosts and CDNs do) avoids the problem for most users.

## A CI pipeline for the whole stack

Run the same checks on every pull request, for both halves at once:

```yaml
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env: { POSTGRES_USER: shop, POSTGRES_PASSWORD: shop, POSTGRES_DB: shop_test }
        ports: ["5432:5432"]
        options: --health-cmd "pg_isready -U shop" --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://shop:shop@localhost:5432/shop_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      - run: npm run build --workspace @shop/web
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
```

The final step is a small **end-to-end smoke test** with Playwright: start the real server with the built frontend, then drive a browser through the critical journeys (sign up, sign in, create an order, see it in the list). Keep it to a handful of tests; they're slower and more brittle than unit and API tests, and their job is to prove the pieces are wired together.

```ts
// e2e/order.spec.ts
import { expect, test } from '@playwright/test'

test('a signed-in customer can place an order', async ({ page }) => {
  const email = `e2e-${Date.now()}@example.com`
  await page.goto('/register')
  await page.getByLabel('Email').fill(email)
  await page.getByLabel('Password').fill('correct horse battery')
  await page.getByRole('button', { name: 'Create account' }).click()

  await page.goto('/orders/new')
  await page.getByLabel('SKU').fill('PEN-01')
  await page.getByRole('button', { name: 'Create order' }).click()
  await expect(page.getByText(/Created order #\d+/)).toBeVisible()
})
```

After CI passes on the main branch, build the image once, deploy it to **staging**, run the smoke tests against staging, then promote the **same image** to production. Many platforms can also deploy every pull request to a temporary **preview environment**, which lets reviewers click through a change before it merges.

> [!TIP]
> Track errors in the browser too, not just on the server. A frontend error-tracking service (with your source maps uploaded during the build) shows you the JavaScript exceptions real users hit, which server logs never see.

## Practice

Ship the `shop` app:

1. Serve the built frontend from the API with the cache headers and SPA fallback above. Check with `curl -I` that `/assets/<file>.js` gets the immutable header, `/` and `/orders/42` get `index.html` with `no-cache`, and `/api/nope` gets a JSON 404.
2. Build the Docker image and run it with `DATABASE_URL` pointing at your PostgreSQL. Open the app, sign in and create an order.
3. Add the `vite:preloadError` handler, and simulate the problem: load the app, rebuild the frontend with a change to a lazy route, delete the old assets, and click the lazy route. The page should reload once and work.
4. Write one Playwright smoke test for sign-in and run it against the container.

**Solution**

```bash
npm run build --workspace @shop/web
cd apps/api && node src/server.ts &
sleep 1
JS=$(ls ../web/dist/assets/*.js | head -1 | xargs basename)
curl -sI localhost:3000/assets/$JS | grep -i cache-control      # public, max-age=31536000, immutable
curl -sI localhost:3000/ | grep -i cache-control                 # no-cache
curl -s localhost:3000/orders/42 | grep -o '<div id="root">'     # the SPA shell, rendered by the client router
curl -si localhost:3000/api/nope | head -1                       # HTTP/1.1 404 Not Found (JSON body)
```

```bash
docker build -t shop .
docker run --rm -p 3000:3000 -e DATABASE_URL=postgres://shop:shop@host.docker.internal:5432/shop shop
```

```ts
// e2e/sign-in.spec.ts
import { expect, test } from '@playwright/test'

test('sign in and see the orders page', async ({ page, request }) => {
  const email = `e2e-${Date.now()}@example.com`
  await request.post('/api/auth/register', { data: { email, password: 'correct horse battery' } })

  await page.goto('/orders')
  await expect(page).toHaveURL(/\/login\?next=%2Forders/)     // the route guard redirected
  await page.getByLabel('Email').fill(email)
  await page.getByLabel('Password').fill('correct horse battery')
  await page.getByRole('button', { name: 'Sign in' }).click()
  await expect(page).toHaveURL(/\/orders$/)                   // back where the user was going
})
```

```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: 'e2e',
  use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000' },
})
```

The test registers through the API (`request.post`) rather than the UI, because registration isn't what it's testing: setting up state through the API keeps end-to-end tests fast and focused. It then checks the whole sign-in journey: the guard's redirect with `next`, the login form, and the return to `/orders`. For step 3, the old tab requests a chunk that no longer exists, Vite fires `vite:preloadError`, the handler reloads once, and the fresh `index.html` (never cached, thanks to `no-cache`) points at the new chunks.

## Key takeaways

- Serve the built SPA and the API from one server and origin: API routes first, a JSON 404 for unknown `/api` paths, static files, then an `index.html` fallback.
- Cache hashed assets for a year as `immutable`, and serve `index.html` with `no-cache` so deploys take effect immediately.
- Build one Docker image from one commit, with cached dependency layers, a type check in the build, production dependencies only and a non-root user.
- Run migrations before or at the start of a deploy, and use expand and contract so old and new code both work with the schema.
- Keep API changes backwards compatible for tabs running old JavaScript, recover from missing chunks by reloading, and gate releases on typecheck, tests, build and a small end-to-end smoke test.

## Quiz

1. Why can assets/index-3f9a2c1b.js be cached for a year, but index.html must not be?

   A. JavaScript files are smaller
   B. Hashed asset names change whenever their content changes, so a URL's content never changes; index.html keeps the same URL and must point to the newest bundles
   C. Browsers never cache HTML
   D. CDNs can't cache HTML

   **Answer: B.** Content-hashed URLs are immutable by construction. index.html is the entry point that references them, so it must be revalidated to pick up new versions.

2. A user reloads https://shop.example/orders/42 and gets a 404 from the server. What's missing?

   A. A CORS header
   B. A database index
   C. The SPA fallback that serves index.html for unknown non-API paths
   D. An immutable cache header

   **Answer: C.** There's no file at /orders/42; the client-side router handles it. The server must return index.html for such paths so the app can boot and render the route.

3. You need to rename the column customer_email to email. What's the safe approach with rolling deploys?

   A. Rename it in one migration and deploy
   B. Expand and contract: add the new column, write both, backfill, switch reads, then drop the old column in a later release
   C. Take the site down for the rename
   D. Rename it only in the frontend

   **Answer: B.** During a rolling deploy, old code still reads the old column. Expand and contract keeps every running version working at every step.

4. Why promote the same Docker image from staging to production instead of rebuilding for production?

   A. Rebuilding is slow
   B. Docker can't rebuild images
   C. Production needs a different Node version
   D. The artifact you tested is exactly the artifact you release; a rebuild could pick up different dependencies or code

   **Answer: D.** Build once, deploy many times: the bits that passed staging's checks are the bits that run in production.
