DEV Community

Cover image for Five Clients, Five Node Versions: Per-Project Toolchains with .nvmrc, Corepack and Docker Compose
Abid Ali
Abid Ali

Posted on

Five Clients, Five Node Versions: Per-Project Toolchains with .nvmrc, Corepack and Docker Compose

Freelancing for several clients at once means living in several codebases at once. That part is fine. The part that hurts is that every codebase was started at a different time, by a different person, with a different idea of what "current" meant.

One client's Next.js app is happy on the latest Node LTS. Another is an older Express API that breaks in subtle ways on anything newer than the version it was built on. One project uses pnpm, one uses Yarn, one uses plain npm and has a package-lock.json that nobody dares to regenerate. Two of them want Postgres, one wants MySQL, and all three want port 5432 or 3306 on your laptop.

If you've ever run npm install in the wrong project with the wrong Node version and spent an hour working out why the lockfile suddenly changed in four hundred places, this post is for you.

Here's the setup I use so that switching between client projects is a cd and nothing else: the right Node version, the right package manager, and the right database, picked up automatically from files that live in the repo.

Rule one: the repo describes its own toolchain

The goal is that nothing about a project lives only in my head or only on my machine. If a project needs Node 20, the repo says so. If it needs pnpm 9, the repo says so. When I hand the project back to the client, or another developer joins, the same files tell them the same thing.

Three small files do most of the work:

client-project/
  .nvmrc                 # Node version
  package.json           # "packageManager" and "engines"
  compose.yaml           # local databases and services
Enter fullscreen mode Exit fullscreen mode

Pin the Node version with .nvmrc

.nvmrc is a one-line file that version managers read:

20
Enter fullscreen mode Exit fullscreen mode

You can pin an exact version like 20.18.1, but for most client work I pin the major version. It's specific enough to avoid surprises and loose enough to pick up security patches.

Several version managers understand this file. I use fnm because it's fast and can switch automatically when you change directories. In ~/.zshrc or ~/.bashrc:

eval "$(fnm env --use-on-cd --version-file-strategy=recursive)"
Enter fullscreen mode Exit fullscreen mode

Now cd ~/work/clients/acme/api switches to whatever that project's .nvmrc says. If that version isn't installed yet, fnm install (run inside the folder) reads the same file and fetches it. If you prefer nvm, it has a documented shell hook that calls nvm use when a .nvmrc is present; it's a bit slower, but it works the same way in principle.

Also add the version to package.json so tools and CI can see it:

{
  "engines": {
    "node": ">=20 <21"
  }
}
Enter fullscreen mode Exit fullscreen mode

By default npm only warns when engines doesn't match. If you want a hard stop, add engine-strict=true to the project's .npmrc. I do this on older client projects where I know a newer Node genuinely breaks things, and leave it off on modern ones.

Pin the package manager with packageManager

The packageManager field in package.json records which package manager and version the project uses:

{
  "packageManager": "pnpm@9.15.0"
}
Enter fullscreen mode Exit fullscreen mode

Corepack reads this field and runs that exact version when you type pnpm or yarn inside the project. On the Node versions most client projects use today, Corepack ships with Node but is disabled by default, so you enable it once:

corepack enable
Enter fullscreen mode Exit fullscreen mode

One caveat worth checking against the Node release notes for your version: Corepack's place in Node itself has been changing, and newer Node releases may not bundle it. If corepack isn't found, install it globally with npm install -g corepack and carry on. The packageManager field stays the same either way.

The payoff is that "which pnpm was this lockfile made with?" stops being a question. The repo answers it.

I also add a tiny guard so I can't accidentally use the wrong tool on a project:

{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the one thing in this post that's a habit rather than a standard, and I only add it to repos I own or where the client agrees.

Databases per project with Docker Compose

Installing Postgres and MySQL directly on your laptop works until two clients want different major versions. Docker Compose solves this neatly, as long as you give each project its own name and its own ports.

# compose.yaml
name: acme-api

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: acme
      POSTGRES_PASSWORD: acme_local_only
      POSTGRES_DB: acme
    ports:
      - "127.0.0.1:54321:5432"
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:
Enter fullscreen mode Exit fullscreen mode

A few details make this painless across many clients:

  • name: at the top sets the Compose project name. Without it, the name comes from the folder, and two folders called api from different clients would end up sharing containers and volumes.
  • A unique host port per project. I keep a small table in my notes (acme 54321, northwind 54322, and so on) so two projects can run at the same time without fighting over 5432.
  • Bind to 127.0.0.1. The database is for me, not for everyone on the café Wi-Fi.
  • Obviously-local passwords. acme_local_only can never be mistaken for a real credential. Real credentials stay out of the repo entirely.

The project's .env.example then points at the right port:

DATABASE_URL=postgres://acme:acme_local_only@localhost:54321/acme
Enter fullscreen mode Exit fullscreen mode

Starting and stopping is per project:

docker compose up -d      # start this client's services
docker compose down       # stop them, keep the data
docker compose ls         # what's running across all projects?
Enter fullscreen mode Exit fullscreen mode

docker compose ls is the one I lean on at the end of the day. It lists every running Compose project, so I can see that I left three clients' databases running and shut down the ones I'm not using.

A doctor script that checks everything in one go

The last piece is a short script I drop into each project as scripts/doctor.mjs. It runs in a couple of seconds and tells me whether my machine matches what the repo expects.

// scripts/doctor.mjs
import { readFileSync, existsSync } from "node:fs";
import { execSync } from "node:child_process";

const results = [];
const check = (name, ok, hint = "") => results.push({ name, ok, hint });

// 1. Node version matches .nvmrc (major version)
if (existsSync(".nvmrc")) {
  const wanted = readFileSync(".nvmrc", "utf8").trim().replace(/^v/, "");
  const have = process.versions.node;
  check(
    `Node ${have} matches .nvmrc (${wanted})`,
    have.split(".")[0] === wanted.split(".")[0],
    "Run: fnm use"
  );
}

// 2. Package manager matches "packageManager"
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
if (pkg.packageManager) {
  const [pm, version] = pkg.packageManager.split("@");
  let have = "";
  try {
    have = execSync(`${pm} --version`, { encoding: "utf8" }).trim();
  } catch {}
  check(
    `${pm} ${have || "(missing)"} matches ${version}`,
    have === version.split("+")[0],
    "Run: corepack enable"
  );
}

// 3. Every key in .env.example exists in .env
if (existsSync(".env.example")) {
  const keys = (file) =>
    readFileSync(file, "utf8")
      .split("\n")
      .map((l) => l.trim())
      .filter((l) => l && !l.startsWith("#"))
      .map((l) => l.split("=")[0]);
  const have = existsSync(".env") ? new Set(keys(".env")) : new Set();
  const missing = keys(".env.example").filter((k) => !have.has(k));
  check(
    missing.length ? `.env missing: ${missing.join(", ")}` : ".env has every key",
    missing.length === 0,
    "Copy the missing keys from .env.example"
  );
}

// 4. Docker is running if the project has a compose file
if (existsSync("compose.yaml") || existsSync("docker-compose.yml")) {
  let ok = true;
  try {
    execSync("docker info", { stdio: "ignore" });
  } catch {
    ok = false;
  }
  check("Docker is running", ok, "Start Docker Desktop or the docker service");
}

for (const r of results) {
  console.log(`${r.ok ? "✔" : "✘"} ${r.name}${r.ok ? "" : `  → ${r.hint}`}`);
}
process.exit(results.every((r) => r.ok) ? 0 : 1);
Enter fullscreen mode Exit fullscreen mode

Wire it up in package.json:

{
  "scripts": {
    "doctor": "node scripts/doctor.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it with npm run doctor (or pnpm doctor) the first time you open a project after a few weeks away. A typical output looks like this:

✔ Node 20.18.1 matches .nvmrc (20)
✘ pnpm 8.15.4 matches 9.15.0  → Run: corepack enable
✘ .env missing: STRIPE_WEBHOOK_SECRET  → Copy the missing keys from .env.example
✔ Docker is running
Enter fullscreen mode Exit fullscreen mode

Two red lines, two obvious fixes, no mystery afternoon.

Why this matters more for freelancers

On a salaried team, someone usually owns the dev environment. As a freelancer, you're the team, and you're the team on five projects at once. Every minute spent fighting your laptop is a minute you either don't bill or bill awkwardly.

It also changes how the work looks to the client. When a project comes with .nvmrc, a packageManager field, a compose.yaml and a doctor script, the next developer can be productive on day one, and that includes you, coming back to it in six months for a small paid change. I set this up in the first day or two of a new contract; it's part of what I mean by starting a contract the clean way in my step-by-step guide to using Upwork as a beginner. It's a small investment that keeps paying back every time you switch projects.

The short version

  • .nvmrc plus a version manager that switches on cd.
  • engines in package.json, with engine-strict where a wrong version really breaks things.
  • packageManager in package.json, enforced by Corepack.
  • One compose.yaml per project, with a unique name:, a unique host port and localhost-only binding.
  • A doctor script that checks all of the above in two seconds.

Set it up once per project and switching clients becomes what it should be: changing folders.


Abid Ali is a full stack developer and freelancer in Karachi, Pakistan. He writes beginner-friendly freelancing guides at Digital Dialogue.

Top comments (0)