DEV Community

Cover image for Monorepo Environment Management at Scale
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

Monorepo Environment Management at Scale

We run a monorepo with three packages — shared, api, and worker. The API needs PORT and CORS_ORIGIN. The worker needs QUEUE_CONCURRENCY and JOB_TIMEOUT. Both need DATABASE_URL, REDIS_URL, and JWT_SECRET.

Before CtroEnv v1.1.0, we duplicated schemas. Every package had its own if (!process.env.DATABASE_URL) block, and every time we changed validation rules we had to hunt down every copy.

The Pattern: Define Once, Extend Per-Service

1. Shared Schema Package

// packages/shared/src/index.ts
import { string, pick } from "@ctroenv/core"

export const base = {
  NODE_ENV: pick(["development", "staging", "production"] as const).default("development"),
  DATABASE_URL: string().url().secret().describe("PostgreSQL connection URL"),
  JWT_SECRET: string().secret().min(32).describe("JWT signing secret"),
  REDIS_URL: string().url().secret().optional().describe("Redis connection URL"),
}
Enter fullscreen mode Exit fullscreen mode

No defineEnv() call, no source binding, no side effects. Pure schema.

2. Extend in Each Service

// packages/api/src/env.ts
import { defineEnv, string, number } from "@ctroenv/core"
import { loadEnv } from "@ctroenv/node"
import { base } from "@example/shared-config"

export const schema = {
  ...base,
  PORT: number().port().default(3000),
  HOST: string().default("0.0.0.0"),
  CORS_ORIGIN: string().url().describe("Allowed CORS origin"),
  API_VERSION: string().regex(/^\d+\.\d+$/).describe("API version"),
}

export const env = defineEnv(schema, { source: loadEnv() })
Enter fullscreen mode Exit fullscreen mode
// packages/worker/src/env.ts
import { defineEnv, string, number } from "@ctroenv/core"
import { loadEnv } from "@ctroenv/node"
import { base } from "@example/shared-config"

export const schema = {
  ...base,
  QUEUE_CONCURRENCY: number().int().min(1).default(5),
  WORKER_TIMEOUT: number().int().min(1000).default(30000),
  WORKER_LOG_LEVEL: string().default("info"),
}

export const env = defineEnv(schema, { source: loadEnv() })
Enter fullscreen mode Exit fullscreen mode

Each service validates its own schema against its own source. The shared vars inherit their validators from base. Tighten DATABASE_URL in one place — both services pick it up.

This is also available via defineSchema() and extendSchema() if you prefer a more explicit API:

import { defineSchema, extendSchema } from "@ctroenv/core"

const base = defineSchema({
  DATABASE_URL: string().url(),
})

const apiSchema = extendSchema(base, {
  PORT: number().port().default(4000),
})
Enter fullscreen mode Exit fullscreen mode

extendSchema merges with spread semantics — extension keys override base. Dev mode warns on conflicts.

Running Validation in CI

The CLI has four commands for CI pipelines.

ctroenv validate — Fail on Invalid Values

# .github/workflows/ci.yml
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build -w packages/shared
      - run: npx ctroenv validate --source .env
        working-directory: packages/api
Enter fullscreen mode Exit fullscreen mode

ctroenv check — Detect Drift

      - run: npx ctroenv check
        working-directory: packages/api
Enter fullscreen mode Exit fullscreen mode

Reports vars in the schema but not in .env, and vars in .env but not in the schema (typos, leftovers).

ctroenv generate — Keep .env.example in Sync

npx ctroenv generate
Enter fullscreen mode Exit fullscreen mode

Produces:

# DATABASE_URL (required)
# PostgreSQL connection URL
# DATABASE_URL=

# PORT (optional, default: 3000)
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Secret variables are commented out. Variables with defaults are filled in. Commit the generated file — it stays in sync because it's derived from the same schema.

ctroenv docs — Auto-Generate Documentation

npx ctroenv docs
Enter fullscreen mode Exit fullscreen mode

Produces ENVIRONMENT.md with type, required status, default, and description for every variable. JSON output (--format json) also available for custom tooling.

Putting It Together

name: Validate Environment
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: [api, worker]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build -w packages/shared

      - name: Validate env vars
        run: npx ctroenv validate --source .env
        working-directory: packages/${{ matrix.package }}

      - name: Check env file consistency
        run: npx ctroenv check
        working-directory: packages/${{ matrix.package }}

      - name: Verify .env.example is up to date
        run: |
          npx ctroenv generate && git diff --exit-code .env.example
        working-directory: packages/${{ matrix.package }}
Enter fullscreen mode Exit fullscreen mode

The last step is the most useful: regenerate .env.example and fail if it differs from what's committed. If the PR author forgot to commit the updated example, the pipeline catches it.

The Config File

// ctroenv.config.ts
import { defineConfig } from "@ctroenv/cli"

export default defineConfig({
  schema: "./src/env.ts",
  sources: { default: ".env" },
  output: { example: ".env.example", docs: "ENVIRONMENT.md" },
  secrets: { mask: ["JWT_SECRET", "DATABASE_URL"], maskWith: "****" },
})
Enter fullscreen mode Exit fullscreen mode

With this in place, npx ctroenv validate (no flags) reads the config, discovers the schema, and applies settings automatically.

What You Get

  • Single source of truth for shared vars. Change DATABASE_URL validation in one place, every service gets the update.
  • Per-service autonomy. The API can add CORS_ORIGIN without the worker knowing.
  • Docs that stay fresh. The schema that validates at startup also generates .env.example and ENVIRONMENT.md.

Final article: Building Your Own CtroEnv Validators — how createValidator() and applyChain() work, and how to build custom validators for semver strings, IP addresses, and AWS ARNs.

Resources: Docs · GitHub · CLI reference

Top comments (0)