Skip to main content

DevOps Reference

DevOps at S&P is the infrastructure, tooling, and automation that makes the development workflow reliable and repeatable. The goal is that every project starts fast, builds consistently, and deploys predictably. This appendix is the lookup reference for environment setup, pipeline configuration, and container patterns -- use the heading list to jump to the concept you need.


Scope

This appendix covers DevOps-specific implementation patterns for S&P projects. For cross-cutting practices, see the main playbook sections:

Examples use Node.js with pnpm as the reference implementation. The principles are portable -- adapt patterns for your project's stack.


Environment setup and tooling

Every S&P project should be runnable on a fresh machine with minimal manual steps. The fewer things a developer has to "just know," the healthier the project. This section covers the specific tools and configurations that make that possible.

Node.js version management

Use nvm with an .nvmrc file at the repository root. This removes all ambiguity about which Node version the project expects.

# .nvmrc
22

When you open the project, run nvm use. If the version is not installed, nvm install handles it. Some teams configure their shell to auto-switch on cd -- that is fine, but do not require it. The .nvmrc file is the contract -- everything else (CI Dockerfiles, deployment configs) should read from it or pin the same version.

Package manager

pnpm is the recommended package manager for new projects. It is faster than npm, uses less disk space through content-addressable storage, and handles monorepo workspaces natively. Pin the version in package.json so every developer and CI runner uses the same version:

{
"packageManager": "pnpm@10.x"
}

Why pnpm over npm or yarn:

  • Content-addressable storage. pnpm stores each package version once on disk and hard-links it into projects. On a machine with multiple projects sharing dependencies (every S&P developer laptop), this saves gigabytes.
  • Strict node_modules. pnpm creates a non-flat node_modules by default. Packages can only import what they explicitly declare as dependencies, not whatever happens to be hoisted. This catches missing dependency declarations that npm silently allows.
  • Workspace support. pnpm workspaces handle monorepo inter-package linking, filtering (pnpm --filter api dev), and workspace:* references natively. No need for a separate monorepo tool for basic use cases.
  • Speed. pnpm installs are consistently faster than npm, especially on CI where the content-addressable store can be cached.

Existing projects on npm or yarn do not need to migrate for migration's sake. The tooling works, and the migration cost is not justified unless the team is actively hitting problems (install speed, phantom dependencies, monorepo limitations). New projects should start with pnpm unless there is a specific reason not to.

IDE

Cursor is the preferred IDE. It is a VS Code fork with integrated AI capabilities, so it runs all VS Code extensions and respects .vscode/ settings. Projects should commit both .vscode/ (settings, launch configs, recommended extensions) and .cursor/rules/ (AI coding standards) to the repo. For guidance on what to put in .cursor/rules/, CLAUDE.md, and how to build AI-assisted workflows, see AI-Assisted Engineering.

Engineers who prefer VS Code can use it -- the configs are compatible. What matters is that the project's shared settings are committed, not which editor opens them.

Include a .vscode/extensions.json to prompt developers for essential extensions on first open:

  • Biome (new projects) or ESLint + Prettier (existing projects)
  • Tailwind CSS IntelliSense (frontend projects)
  • GitLens
  • Error Lens
  • Todo Tree
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"eamodio.gitlens",
"usernamehw.errorlens",
"Gruntfuggly.todo-tree"
]
}

Polyglot note: For non-Node projects, use equivalent version managers: goenv or asdf for Go, pyenv for Python, sdkman for Java/Kotlin. Package managers map similarly: Go modules (built-in), pip with requirements.txt or Poetry with pyproject.toml, Maven or Gradle for Java. The .nvmrc equivalent is .go-version, .python-version, or .sdkmanrc respectively. The principle is the same -- pin the runtime version in a dotfile that tooling reads automatically.


Linting and formatting

Consistent code style is not a matter of taste -- it eliminates an entire category of code review noise and makes diffs about logic, not whitespace. S&P projects use a single tool configuration committed to the repo so that every developer and CI run produces identical results.

New projects: Biome

Starting with NestJS 12 projects, use Biome as the single tool for linting and formatting. Biome replaces both ESLint and Prettier with one binary -- it is faster (written in Rust), requires less configuration, and eliminates the class of bugs caused by ESLint and Prettier configs conflicting with each other.

{
"$schema": "https://biomejs.dev/schemas/2.0/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}

Biome covers TypeScript, JavaScript, JSX, TSX, JSON, and CSS. For file types Biome does not support yet (Markdown, YAML), keep Prettier as a secondary formatter scoped only to those file types.

Existing projects: ESLint + Prettier

Projects already on ESLint + Prettier do not need to migrate mid-flight. The tooling works, and the migration cost is not justified until a natural break point -- a major framework upgrade, a new project phase, or a significant repo restructure. When the project does migrate, Biome provides an automated migration command:

biome migrate eslint

This converts most ESLint rules to Biome equivalents. A manual pass is still needed for custom rules and plugin-specific configurations, but the command handles the bulk of the work.

Polyglot note: Go uses gofmt (formatting, built-in) and golangci-lint (linting, aggregates multiple linters). Python uses ruff (Rust-based linter and formatter that replaces flake8, isort, and black in a single tool -- the Python equivalent of Biome). Java uses Checkstyle for style enforcement and SpotBugs for bug detection. .NET uses dotnet format and Roslyn analyzers. The principle is the same: one formatting tool, one linting tool, both enforced in CI.


Git hooks

Automate what humans will forget. Linting on save is a suggestion; linting on commit is a guarantee. Git hooks catch issues before they reach CI, shortening the feedback loop from minutes to seconds.

New projects: Lefthook

Use Lefthook for git hooks. It is faster than Husky, configured in a single YAML file, runs tasks in parallel, and does not require npm lifecycle scripts to install.

# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,jsx,ts,tsx}"
run: pnpm biome check --write --staged {staged_files}
format:
glob: "*.{html,json,css,scss,md,mdx}"
run: pnpm biome format --write --staged {staged_files}

commit-msg:
commands:
commitlint:
run: pnpm exec commitlint --edit {1}

Lefthook's parallel: true runs lint and format simultaneously. On a typical commit touching 10-20 files, this completes in under 2 seconds. The {staged_files} placeholder ensures only staged files are checked -- no wasted work on unstaged changes.

Existing projects: Husky + lint-staged

Projects already using Husky + lint-staged can stay on them. The configuration is straightforward:

{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
"*.{html,json,css,scss,md,mdx}": ["prettier --write"]
}
}

The --max-warnings=0 rationale

The --max-warnings=0 flag is intentional and applies to both Lefthook and Husky setups. Warnings that are permanently ignored are worse than no warnings at all -- they train the team to ignore the linter output entirely. Either fix the warning, disable the rule with a documented reason, or suppress it per-line with a comment explaining why. A clean lint output means every warning that appears is real and actionable.

Polyglot note: The pre-commit framework is the standard in the Python ecosystem -- it supports hooks for any language via a YAML config. Lefthook is language-agnostic and works well for Go, Java, and mixed-language repos. The principle is the same: automated checks before code enters the commit history.


Monorepo structure

S&P projects typically use a pnpm workspace monorepo. This is not a dogmatic choice -- it solves real problems. A monorepo keeps related applications (API, web, cloud functions) in the same repo so that cross-cutting changes (shared types, database schema updates, CI pipeline changes) are atomic commits rather than coordinated multi-repo merges.

Standard layout

project-root/
apps/
api/ # NestJS backend
web/ # Next.js frontend
functions/ # Cloud functions (if applicable)
libs/
shared-lib/ # Shared code consumed via workspace:*
infrastructure/ # Terraform / IaC
cicd/ # CI pipeline configs
.vscode/ # Shared IDE settings
.cursor/rules/ # AI coding standards
.husky/ or lefthook.yml # Git hooks
pnpm-workspace.yaml # Workspace definition
.nvmrc # Node version

Why this layout:

  • apps/ separates independently deployable applications. Each app has its own package.json, its own build, and its own CI trigger.
  • libs/ holds shared code that multiple apps consume. Shared types, utility functions, validation schemas -- anything that would otherwise be duplicated.
  • infrastructure/ keeps IaC in the same repo as the code it provisions. Changes to infrastructure and application code can be reviewed together.
  • cicd/ contains pipeline configurations, Dockerfiles, and deployment scripts. Keeping them in a dedicated directory rather than scattered across the root makes them discoverable.

Workspace configuration

Define workspaces in pnpm-workspace.yaml:

packages:
- 'apps/*'
- 'libs/*'

Shared libraries use workspace:* references in package.json -- pnpm resolves these to the local package at install time. This avoids publishing internal packages while keeping dependency declarations explicit:

{
"dependencies": {
"@project/shared-lib": "workspace:*"
}
}

When you run pnpm install, pnpm symlinks the local package into node_modules. When you run pnpm --filter api build, pnpm resolves the workspace reference and includes the shared library in the build.

Polyglot note: Nx and Turborepo are alternatives for JavaScript/TypeScript monorepos that add task caching, dependency graph visualization, and affected-command detection on top of pnpm workspaces. Go uses multi-module workspaces (go.work file). .NET uses solution files (.sln) with project references. Java uses Maven multi-module or Gradle composite builds. The pattern is identical: a root configuration that declares which directories contain independently buildable units.


Local development infrastructure

Every project with external dependencies (database, cache, message queue) should include a docker-compose.yml that starts them with a single command. No developer should need to install database software directly on their machine -- it creates version drift, pollutes the system, and makes onboarding dependent on tribal knowledge.

PostgreSQL

PostgreSQL 18 is the standard for new S&P projects. Pin the major version in Docker Compose to prevent silent upgrades:

services:
db:
image: postgres:18-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: app_dev
volumes:
- pgdata:/var/lib/postgresql/data

volumes:
pgdata:

The alpine variant keeps the image small (~80MB vs ~400MB for the full image). The named volume (pgdata) persists data across container restarts -- docker compose down stops the container but keeps your data. Use docker compose down -v to wipe the volume when you want a clean slate.

Redis

For projects that need caching, session storage, or pub/sub:

services:
db:
image: postgres:18-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: app_dev
volumes:
- pgdata:/var/lib/postgresql/data

redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

volumes:
pgdata:

The maxmemory and eviction policy prevent Redis from consuming unbounded memory in development. For production Redis configuration, use managed services (GCP Memorystore, AWS ElastiCache) -- never run Redis in a container in production.

Additional services

Follow the same pattern for any service the project depends on:

ServiceImageTypical use
PostgreSQLpostgres:18-alpinePrimary database
Redisredis:7-alpineCaching, sessions, pub/sub
MinIOminio/minioLocal S3-compatible storage
Mailpitaxllent/mailpitLocal email testing (SMTP trap)
RabbitMQrabbitmq:3-management-alpineMessage queue

Keep the docker-compose.yml at the repository root. Name services descriptively (db, redis, storage, not service1, service2). Document any non-obvious configuration in comments within the YAML file.

Polyglot note: The Docker Compose pattern is entirely language-agnostic. A Go project, a Python Django project, and a Java Spring project all use the same docker-compose.yml for their infrastructure dependencies. Only the application service (if you containerize the app itself for local dev) changes between stacks.


Security pipeline

S&P runs an automated security scan pipeline in CI. This is not optional -- every project uses this pipeline or an equivalent. The pipeline is defined in the engineering-forward template and runs on every PR and on merges to protected branches (main, development, staging).

Pipeline architecture

security-scan-gate
|
+-- lint-and-audit (ESLint + pnpm audit)
+-- dependency-scan (Trivy SCA + SBOM + license)
+-- code-analysis (Semgrep + Gitleaks)
+-- container-scan (Trivy image scan)
|
consolidate-results (summary + pass/fail gate)

All scan jobs run in parallel after the gate check. Results are stored as CI artifacts and consolidated into a summary report.

Scan stages

StageToolWhat it scansSeverity threshold
Lint + auditESLint, pnpm auditCode quality, known npm advisoriesHIGH+
SCA vulnerabilitiesTrivyDependencies (filesystem scan)CRITICAL, HIGH
SAST misconfig + secretsTrivyInfrastructure-as-code, embedded secretsAll
SAST code patternsSemgrepOWASP Top 10, JS/TS/Node security rulesAll
Secret detectionGitleaksGit commit history (incremental)All
SBOM generationTrivyFull dependency tree (CycloneDX format)N/A (output only)
License complianceTrivyDependency licensesMEDIUM+
Container scanTrivyDocker images (API + Web)CRITICAL, HIGH

Failure modes

The pipeline supports two modes via the failure-mode parameter:

  • fail (default) -- The pipeline fails and blocks the merge if any issues are found at or above the severity threshold. Use this for protected branches (main, development, staging). This is the mode every mature project should run.
  • report -- The pipeline generates the full report but does not block the merge. Use this during initial adoption when an existing project first enables the pipeline and needs to triage existing findings without blocking all development. The goal is always to move to fail mode -- report is a transition state, not a permanent configuration.

Semgrep rulesets

The pipeline runs Semgrep with these rulesets:

  • p/javascript, p/typescript, p/nodejs -- Language-specific security patterns (unsafe regex, prototype pollution, path traversal)
  • p/owasp-top-ten -- OWASP Top 10 vulnerability patterns (injection, XSS, SSRF, broken auth)
  • p/security-audit -- Broader security audit rules (crypto misuse, insecure defaults, missing security headers)

Semgrep runs fast (seconds, not minutes) because it uses pattern matching rather than full program analysis. It catches the common vulnerability patterns that developers introduce without thinking -- eval() usage, SQL string interpolation, hardcoded credentials in test files that accidentally ship.

Gitleaks scope

Gitleaks scans incrementally -- only the commits in the current branch since it diverged from main/development/staging. This keeps the scan fast while still catching secrets introduced in the current work. It detects API keys, tokens, passwords, private keys, and other credential patterns using a comprehensive ruleset.

The incremental approach matters for performance: a full-history scan on a large repo can take minutes. Scanning only the branch delta completes in seconds and catches exactly the secrets that would be introduced by merging this branch.

Secrets in code -- zero tolerance

If Gitleaks or Trivy detects a secret in your code:

  1. Do not just delete it and push. The secret is already in git history. Deleting it from the latest commit does not remove it from the repository.
  2. Rotate the credential immediately. Assume it is compromised. Generate a new key/token/password.
  3. Remove it from git history using git filter-branch or BFG Repo-Cleaner. This rewrites history, so coordinate with the team.
  4. Add the pattern to .gitignore or the project's Gitleaks allowlist (for false positives only -- with a comment explaining why it is a false positive).

This process is non-negotiable. A rotated credential is a 5-minute task. A leaked credential that reaches production is an incident with client notification, audit trail, and trust damage.

Polyglot note: Semgrep supports Go, Python, Java, C#, Ruby, and many other languages natively -- the same pipeline configuration works for non-Node projects by adding the appropriate language rulesets (p/golang, p/python, p/java). Trivy and Gitleaks are entirely language-agnostic. When adapting the pipeline for a non-Node project, replace pnpm audit with the equivalent dependency audit tool (go vuln check, pip-audit, mvn dependency-check).


Container security

Containers are the deployment unit for every S&P project running on Cloud Run, Kubernetes, or any container-based platform. The security of the container image is as important as the security of the application code inside it -- a vulnerable base image or a misconfigured Dockerfile can undo all application-level security work.

Base images

Use minimal distributions to reduce attack surface:

  • alpine variants -- Small (~5MB base), includes a package manager for runtime dependencies. Use for most applications.
  • distroless -- Even smaller, no shell, no package manager. Use for compiled applications (Go binaries) or when you need the smallest possible attack surface.

Never use latest tags for base images. Pin the major version at minimum (node:22-alpine, not node:alpine), and ideally pin the full version in production Dockerfiles (node:22.14-alpine3.21).

Non-root execution

Run containers as a non-root user. The S&P template Dockerfiles create a dedicated user for each application type. This limits the blast radius if the application is compromised -- an attacker who gains code execution cannot write to system directories or access other processes.

Multi-stage builds

Multi-stage builds ensure only the compiled output and runtime dependencies end up in the final image. Source code, build tools, dev dependencies, and intermediate artifacts stay in the build stage and never ship to production.

Example: NestJS API Dockerfile

# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
COPY libs/shared-lib/package.json libs/shared-lib/
RUN corepack enable pnpm && pnpm install --frozen-lockfile

# Stage 2: Build
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/libs/shared-lib/node_modules ./libs/shared-lib/node_modules
COPY . .
RUN pnpm --filter api build

# Stage 3: Production image
FROM node:22-alpine AS production
WORKDIR /app

RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 appuser

COPY --from=build --chown=appuser:appgroup /app/apps/api/dist ./dist
COPY --from=build --chown=appuser:appgroup /app/apps/api/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/apps/api/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]

Key points:

  • Stage 1 (deps) installs dependencies from lockfile only. This layer is cached unless package.json or pnpm-lock.yaml changes.
  • Stage 2 (build) copies source code and compiles. This layer rebuilds on every code change but does not ship.
  • Stage 3 (production) copies only the compiled output, production node_modules, and package.json. No source code, no dev dependencies, no build tools.
  • The appuser with UID 1001 runs the process as non-root.
  • --frozen-lockfile ensures CI builds are deterministic -- if the lockfile is out of sync with package.json, the install fails rather than silently updating.

What must not be in production images

  • .env files or any file containing secrets
  • Source code (.ts files, src/ directory)
  • Dev dependencies (devDependencies from package.json)
  • Build tools (TypeScript compiler, Webpack, Biome)
  • Test files and test fixtures
  • .git directory

If any of these appear in your production image, the Dockerfile needs restructuring. Use a .dockerignore file to prevent accidental inclusion:

.git
.env*
*.md
docs/
**/*.spec.ts
**/*.test.ts

Image scanning in CI

Trivy scans every container image as part of the security pipeline. The scan runs against the built image (not just the Dockerfile) so it catches vulnerabilities in the base image, installed system packages, and application dependencies.

Fix CRITICAL and HIGH findings before deploying. MEDIUM findings should be tracked and addressed within the sprint. LOW findings go to the backlog.

Polyglot note: The same container security principles apply regardless of application language. Go applications typically use scratch or distroless base images (the compiled binary has no runtime dependencies). Python applications use python:3.x-slim or python:3.x-alpine. Java applications use eclipse-temurin:21-jre-alpine or Google's distroless/java21-debian12. In every case: minimal base image, non-root user, multi-stage build, no secrets in the image.


Resources

S&P internal:

Tooling:

  • nvm -- Node version management
  • pnpm -- Package manager
  • Biome -- Linter + formatter (new projects)
  • Lefthook -- Git hooks (new projects)
  • Trivy -- Vulnerability scanner (dependencies, containers, IaC)
  • Semgrep -- Static analysis for security patterns
  • Gitleaks -- Secret detection in git history
  • Docker -- Container runtime

Industry references: