Skip to content

41 β€” The Full-Stack Project β€” Build, Configuration and Deployment

Level: Advanced Β· Time: ~50 min Β· Prerequisites: Lesson 40 β€” A Backend in TypeScript


Why this matters

Everything so far ran on your machine: two terminals, a debugger, a database you started by hand. Production removes those props. Two runtimes now matter separately β€” a browser that downloads files anybody can read, and a server that holds credentials nobody may read β€” and only what your build produced reaches the server. The failures that follow are rarely about the code: they are configuration reaching the wrong process, an artefact never shipped, or a migration applied after the new code started.

The full-stack picture: development servers, the two build artefacts, the production topology behind a reverse proxy, and what must never reach the browser

Click the diagram to open it full size.

Two runtimes, two kinds of configuration

A browser cannot keep a secret, so the split below is not a matter of taste: it is what each runtime is allowed to know.

Value Where it lives Who can read it To change it
VITE_REGION, VITE_API_BASE .env.production, inlined into the bundle by Vite every visitor, in clear text rebuild the front end
PORT the service's EnvironmentFile the server process restart the service
DATABASE_URL the same file, mode 600, owned by the service user the server process restart the service
LOG_LEVEL, SOLAR_CONCURRENCY the same file the server process restart the service

Read the last column: the front end is static files and needs a rebuild; the API is long-running and needs a restart.

[!WARNING] A VITE_ prefix means "inline this into the bundle". Anyone can open the built file and read the value, so a key placed there is published. Secrets live on the server only.

Build: three artefacts and the scripts that produce them

Two packages, api/ and web/, and one root package.json chains them: the backend is compiled by tsc into dist/, the front end is bundled by vite build, and nothing ships that has not passed tsc -b first.

{
  "name": "solar-tracker",
  "private": true,
  "type": "module",
  "scripts": {
    "typecheck": "npm --prefix api run typecheck && npm --prefix web run typecheck",
    "build": "npm run typecheck && npm --prefix api run build:api && npm --prefix web run build:web",
    "migrate": "npm --prefix api run migrate",
    "start": "node api/dist/server.js"
  }
}

The API's tsconfig.json is the half that matters, because Node runs its output directly: module: "NodeNext" with moduleResolution: "NodeNext" makes import { readConfig } from "./config.js" β€” the .js extension for a TypeScript file, from Lesson 12 β€” correct as well as necessary. tsup src/server.ts --format esm --target node20 can bundle the server instead, but it does not typecheck, so tsc -b stays in the chain.

An image that is small, reproducible and not root

A multi-stage build keeps the compiler, the dev dependencies and the build cache out of the artefact that runs, which is where the megabytes and most of the attack surface live.

# api/Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY prisma ./prisma
RUN npx prisma generate
COPY tsconfig.json ./
COPY src ./src
RUN npm run build:api

FROM node:20-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=build /app/dist ./dist
COPY prisma ./prisma
USER node
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? 8787) + '/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "dist/server.js"]

Four lines carry the weight. npm ci --omit=dev keeps TypeScript, the test runner and the linter out of the shipped image. USER node switches off root, so a compromise cannot write as the superuser β€” and it means logs go to stdout rather than to a file inside the image. prisma generate runs in the build stage, and the HEALTHCHECK makes the container's verdict visible in docker ps, on the route the deploy script calls.

A service, a reverse proxy and HTTPS

The Node process listens on 127.0.0.1:8787; ports 80 and 443 belong to the reverse proxy. A systemd unit of five directives does the job: Type=simple, User=solar, an EnvironmentFile at /etc/solar/api.env with mode 600, ExecStart=/usr/bin/node /opt/solar/dist/server.js, and Restart=on-failure β€” adopted with sudo systemctl daemon-reload and sudo systemctl enable --now solar-api.

Caddy obtains and renews the TLS certificate by itself once the domain resolves to the machine, which is why it is the shorter answer than nginx plus certbot.

solar.example.com {
    encode gzip
    handle /api/* {
        reverse_proxy 127.0.0.1:8787
    }
    handle {
        root * /srv/solar-web
        try_files {path} /index.html
        file_server
    }
}

The first block replaces the Vite dev proxy from Lesson 39 with a real one: in production both halves share one origin, so VITE_API_BASE stays empty and no CORS headers are needed. The second serves the built front end. Adopt it with sudo caddy validate --config /etc/caddy/Caddyfile and sudo systemctl reload caddy.

Deploying in this order, and the evidence you check after

Each step depends on the one before it, and a backup taken after a migration is not a backup.

# on the server, in /opt/solar
export STAMP=$(date -u +%Y%m%dT%H%M%SZ)
sudo -u postgres pg_dump -Fc solar_prod > /var/backups/solar/pre-${STAMP}.dump   # 1. backup
docker compose run --rm api npx prisma migrate deploy                           # 2. migrate
docker compose up -d --no-deps api                                              # 3. start
curl -fsS https://solar.example.com/api/health                                  # 4. verify

Migrations go first, as an explicit step: run from application startup, ten replicas would race on the same schema change. Prisma's migrations are forward-only, so rolling back is two actions: point RELEASE_TAG in api.env at the previous image and run docker compose up -d --no-deps api, and if the schema changed, restore the pre-migration dump. Keep changes additive for that reason β€” a new nullable column is ignored by old code.

Observability is two habits rather than a product. Logs go to stdout, so journald or the container runtime keeps them: journalctl -u solar-api -n 50 --no-pager, docker compose logs -f --tail=100 api. The health route answers 200 with {"status":"ok","db":true} and 503 when the query behind it fails. Then verify the whole path: the CLI import still runs against production data, the API returns rows the front end can validate, and the dashboard renders.


Common mistakes

Mistake What happens The fix
A secret in a VITE_ variable It is inlined into a public bundle and readable by anyone Keep secrets in the server environment file; proxy the call through /api
A production install carrying devDependencies A large image holding the compiler and test runner, and more code to patch npm ci --omit=dev in the runtime stage of a multi-stage build
Running the container as root A compromise can write anywhere in the image USER node, and write logs to stdout rather than to a file inside the image
Exposing the Node port directly The API answers plain HTTP on :8787 and bypasses the proxy and its HTTPS Bind to 127.0.0.1 and let Caddy or nginx own 80 and 443
Migrating after the new code starts The application queries columns the schema does not have yet Backup, prisma migrate deploy, then start β€” never migrate from application startup
No health route and no HEALTHCHECK "It started" becomes the only evidence, disproved by a user One cheap route that checks the database, called by the health check and the deploy script
Copying node_modules into the build context Slow builds that occasionally ship a stale tree A .dockerignore with node_modules, dist, web, .git and .env

Try it

  1. Build from a clean checkout with npm ci && npm run build && ls api/dist/server.js web/dist/index.html. Expected: tsc -b prints nothing on success, api/dist/server.js exists, and web/dist/ holds index.html beside hashed assets.

  2. Build the image and run it exactly as the server would:

docker build -t solar-api:local api
docker run --rm -d --name solar-api --env-file /etc/solar/api.env -p 127.0.0.1:8787:8787 solar-api:local
docker inspect --format '{{.State.Health.Status}}' solar-api
curl -fsS http://127.0.0.1:8787/api/health

Expected: healthy after the ten-second start period, then the health JSON. If it says unhealthy, docker logs solar-api names the reason β€” usually a DATABASE_URL the container cannot resolve.

  1. Deploy in the order above, then read the evidence: the health call, journalctl -u solar-api -n 50 --no-pager, and curl -fsS https://solar.example.com/api/days | head -c 200. Expected: no stack traces in the log, and JSON rows from production.

  2. Break it on purpose β€” stop the database and call health (expected 503, not a connection error page), then sudo systemctl kill -s SIGKILL solar-api and watch systemctl status solar-api --no-pager for ten seconds (expected active (running) again). Finally point RELEASE_TAG at the previous image, run docker compose up -d --no-deps api and repeat the health call: expected is 200 from the older release.


Key takeaways

  • Configuration splits by trust: VITE_ values are inlined and need a rebuild; server environment values are read at start and need a restart.
  • The build produces three artefacts behind one gate: tsc for the API, vite build for the browser, tsc -b in front of both.
  • A multi-stage image with npm ci --omit=dev, USER node and a HEALTHCHECK is an image you can rebuild next year.
  • Deployment has an order β€” backup, migrate, start, verify β€” and rollback means the previous image tag plus the pre-migration dump.
  • Observability is logs on stdout and a health route that reports the database honestly.

Check yourself

  1. Your dashboard shows stale figures after a change on the server: which kind of change was it, and which step did you forget?
  2. Why must the health route query the database rather than return {"status":"ok"} unconditionally?
  3. A migration adds a required column with no default: why is that a bad release, and what is the additive version?
  4. Ten replicas each run prisma migrate deploy on boot: name two distinct ways this goes wrong.
  5. You have ninety seconds to decide whether to roll back: which three pieces of evidence do you check?

Next

Lesson 42 β€” Testing with Vitest