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.htmlensures 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
$PORTat runtime; nginx config usesenvsubstto 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-alpineinstead ofnginx:alpine - No DOTENV_KEY build arg:
.env.vaultis decrypted at runtime viadotenv/config, using theDOTENV_KEYenvironment variable set on the Heroku app - Prisma: The
prisma/directory andnode_modulesare copied from the dependencies stage soprisma migrate deployworks 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
| App | Dockerfile | Runtime Image | Build Output |
|---|---|---|---|
| fastlane-portal | apps/fastlane-portal/Dockerfile | nginx:alpine | dist/apps/fastlane-portal |
| fastlane-admin | apps/fastlane-admin/Dockerfile | nginx:alpine | dist/apps/fastlane-admin |
| docs | apps/docs/Dockerfile | nginx:alpine | dist/apps/docs |
| components | libs/ui/components/Dockerfile | nginx:alpine | dist/libs/ui/components |
| fastlane-api-gateway | apps/apis/fastlane-api-gateway/Dockerfile | node:22-alpine | dist/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