Skip to main content

Docker Containerization

All apps use multi-stage Docker builds with a shared pattern: base -> dependencies -> build -> production. The two build patterns diverge at the production stage -- frontend apps serve static files via nginx, while the API gateway runs a Node.js process.

Images are built once in the build-images CI stage and pushed to the GitLab Container Registry. Deploy jobs promote pre-built images to Heroku by retagging and pushing — no rebuild occurs at deploy time.

Multi-Stage Build Pattern

Base Stage

All Dockerfiles start with the same base:

FROM node:22-alpine AS base
WORKDIR /app
RUN npm install -g pnpm@10.18.2

Dependencies Stage

Copies lockfile and workspace config, then installs with --frozen-lockfile:

FROM base AS dependencies
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./
COPY nx.json tsconfig.base.json tsconfig.json ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile

The API gateway additionally runs pnpm exec prisma generate to create the Prisma client.

Build Stage

Copies source code and runs the NX build:

FROM dependencies AS build
COPY apps/<app-path> ./apps/<app-path>
COPY libs ./libs
RUN pnpm exec nx build <app-name> --prod

Frontend Pattern (Portal, Admin, Docs, Storybook)

Frontend apps build to static files and serve via nginx:alpine.

Runtime Config Injection

Portal and admin use runtime config injection instead of build-time environment variables. The image is environment-agnostic — the same image runs in dev, QA, staging, and production.

At container startup, the nginx entrypoint generates /config.js from Heroku config vars:

window.__RUNTIME_CONFIG__ = {
VITE_API_GATEWAY_URL: "https://dev-fastlane-api-gateway.goosehead.com/api",
VITE_DA_BASE_URL: "https://dev.quote.goosehead.com"
};

Application code reads config via getConfig() from @goosehead-fastlane/runtime-config:

  • Production: reads from window.__RUNTIME_CONFIG__ (populated by /config.js)
  • Local dev: falls back to import.meta.env.VITE_* (populated by Vite from .env)

Nginx Runtime

The production stage serves static files with SPA routing and security headers:

FROM nginx:alpine AS production

COPY --from=build /app/dist/apps/<app> /usr/share/nginx/html

Key nginx configuration:

  • SPA routing: try_files $uri $uri/ /index.html ensures client-side routing works
  • Gzip: Enabled for text, CSS, JS, JSON, and XML
  • Security headers: X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, X-XSS-Protection: 1; mode=block
  • Dynamic port: Heroku assigns $PORT at runtime; nginx config uses envsubst to inject it

Heroku Port Handling

Heroku assigns a dynamic $PORT. The Dockerfile creates an entrypoint script that substitutes the port and generates the runtime config at container startup:

RUN printf '#!/bin/sh\n\
envsubst '\''$PORT'\'' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf\n\
cat <<EOF > /usr/share/nginx/html/config.js\n\
window.__RUNTIME_CONFIG__ = {\n\
VITE_API_GATEWAY_URL: "${VITE_API_GATEWAY_URL:-}",\n\
VITE_DA_BASE_URL: "${VITE_DA_BASE_URL:-}"\n\
};\n\
EOF\n\
nginx -g "daemon off;"\n' > /docker-entrypoint.sh

API Gateway Pattern

The API gateway runs as a Node.js process with runtime environment variable loading.

Production Stage

FROM node:22-alpine AS production
WORKDIR /app

COPY --from=build /app/dist/apps/apis/fastlane-api-gateway ./
COPY --from=dependencies /app/prisma ./prisma
COPY --from=dependencies /app/node_modules ./node_modules
COPY .env.vault ./

CMD ["node", "-r", "dotenv/config", "main.js"]

Key differences from frontend:

  • Runtime: node:22-alpine instead of nginx:alpine
  • No DOTENV_KEY build arg: .env.vault is decrypted at runtime via dotenv/config, using the DOTENV_KEY environment variable set on the Heroku app
  • Prisma: The prisma/ directory and node_modules are copied from the dependencies stage so prisma migrate deploy works at release time
  • Procfile: Defines a release phase that runs migrations automatically on Heroku release
web: node -r dotenv/config main.js
release: npx prisma migrate deploy

Build-Once Pipeline Integration

The build-images CI job builds all 5 app images in parallel and pushes to GitLab Container Registry:

build-images:
extends: .build-image
stage: build-images
parallel:
matrix:
- APP_NAME: fastlane-portal
- APP_NAME: fastlane-admin
- APP_NAME: fastlane-api-gateway
- APP_NAME: docs
- APP_NAME: components

Images are tagged as $CI_REGISTRY_IMAGE/<app>:$CI_COMMIT_SHA. Deploy jobs pull and retag for Heroku via ci/scripts/promote-image.sh.

Dockerfiles Reference

AppDockerfileRuntime ImageBuild Output
fastlane-portalapps/fastlane-portal/Dockerfilenginx:alpinedist/apps/fastlane-portal
fastlane-adminapps/fastlane-admin/Dockerfilenginx:alpinedist/apps/fastlane-admin
docsapps/docs/Dockerfilenginx:alpinedist/apps/docs
componentslibs/ui/components/Dockerfilenginx:alpinedist/libs/ui/components
fastlane-api-gatewayapps/apis/fastlane-api-gateway/Dockerfilenode:22-alpinedist/apps/apis/fastlane-api-gateway

Security

All production images:

  • Run as a non-root user (appuser:appgroup, UID/GID 1001)
  • Use Alpine-based images to minimize attack surface
  • Do not include dev dependencies in the final stage