Developer Experience Engineering: Making Engineers Productive on Day One

Design developer tooling and workflows that eliminate friction, reduce wait times, and let engineers focus on building features instead of fighting infrastructure. Covers local development environments, CI/CD experience, documentation, onboarding, inner loop optimization, and the metrics that measure whether your platform is actually helping.

Engineers expect to be productive on their first day. Developer Experience Engineering ensures that new engineers can check out code, run services locally, configure environments, and ship their first feature—all within a single day. This is not about onboarding, but about *engineering experience as a product*: the sum of tools, workflows, and systems that make a developer’s daily work predictable, fast, and frictionless. It matters when teams scale beyond a handful of people, when context switches multiply, and when engineers arrive from diverse backgrounds with different tools and expectations.

Onboarding: From First Git Clone to First Deployment

Onboarding begins the moment a new engineer receives their laptop and opens a terminal. The goal is to go from git clone https://github.com/your-org/my-service.git to curl https://my-service-staging.your-org.com/api/v1/health returning {"status": "ok"} in under 60 minutes.

Install the Developer Toolchain

The first step is a self-contained toolchain that installs all required tools with one command. A dev init script runs a sequence of steps, starting with system-level dependencies:

#!/bin/bash

set -euo pipefail

# Install system packages via package manager
if command -v apt &> /dev/null; then
  sudo apt update
  sudo apt install -y curl git vim jq docker.io python3 python3-pip
elif command -v brew &> /dev/null; then
  brew update
  brew install curl git vim jq docker python3
fi

# Install language-specific tools
curl -sSL https://get.haskell.org | sh
curl -sSf https://astral.sh/ghcup | sh

# Install CLI tools via binary downloads
curl -L https://github.com/charmbracelet/bat/releases/latest/download/bat-0.24.0-x86_64-unknown-linux-gnu.tar.gz \
  | tar -xzf - -C /usr/local/bin --strip-components=1 bat

# Ensure all binaries are in PATH
export PATH="$HOME/.local/bin:$PATH"

This script assumes the developer has bash, curl, git, and tar already present. On failure, the script exits immediately and logs errors to ~/.dev-init.log. The most common silent failure: apt not available on a macOS machine that lacks brew, leading to a missing docker command.

Configure the Local Development Environment

After tool installation, dev setup initializes a local environment tailored to the service. It reads a dev.yaml file in the project root:

# dev.yaml
services:
  - name: api
    image: ghcr.io/your-org/api:latest
    port: 8080
    command: ["./bin/api", "--config", "config/dev.yaml"]
    env:
      DATABASE_URL: postgres://dev:dev@localhost:5432/myapp_dev
      LOG_LEVEL: debug
    volumes:
      - ./data:/app/data
    dependencies:
      - db
      - cache
  - name: db
    image: postgres:15
    port: 5432
    env:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: myapp_dev
    volumes:
      - ./data/pg:/var/lib/postgresql/data
  - name: cache
    image: redis:7
    port: 6379

Running dev setup applies this configuration via docker-compose:

docker-compose -f docker-compose.dev.yaml up -d --wait

The --wait flag ensures services are ready before proceeding. It checks for HTTP 200 on /health and a successful SELECT 1 query against PostgreSQL. If any service fails to start, dev setup logs the last 10 lines of its docker logs output and exits.

The most subtle failure: dev setup starts services but does not wait for the database to be ready. The api service connects to PostgreSQL before the db container has finished initializing, leading to a connection refused error in logs. The fix is to add a healthcheck in dev.yaml:

services:
  - name: db
    image: postgres:15
    port: 5432
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "dev", "-d", "myapp_dev", "-h", "localhost"]
      interval: 1s
      timeout: 3s
      retries: 10

Run the Application with Developer-Friendly Commands

Once services are up, dev run starts the main application with logging, auto-reload, and debugging enabled:

dev run api

This command runs:

docker exec -it \
  --env LOG_LEVEL=debug \
  --env NODE_OPTIONS="--inspect=9229" \
  my-service_api_1 \
  ./bin/api \
  --config config/dev.yaml \
  --watch \
  --log-stdout

The --watch flag triggers fsnotify to restart the process when source files change. The --log-stdout flag adds timestamps and structured JSON logs:

{
  "timestamp": "2024-05-10T12:34:56.789Z",
  "level": "info",
  "service": "api",
  "message": "Starting server on port 8080",
  "pid": 42
}

When a new developer runs dev run api and sees no logs, the most common cause is a misconfigured docker-compose YAML: api service is named api in dev.yaml, but docker-compose uses my-service_api_1. The fix is to ensure the container_name is explicitly set in dev.yaml:

services:
  - name: api
    image: ghcr.io/your-org/api:latest
    container_name: my-service_api
    # ...

Debug and Test: Local Workflow for First Feature

With the application running, the developer can make a change and test it immediately. The dev test command runs all tests with coverage and a test server:

dev test --watch --coverage --server

This command does:

  1. Starts a test database in a separate Docker container.
  2. Runs npm run test:unit with --coverage and --watch.
  3. Starts a test server with --env=test.
  4. Runs integration tests against the test server.

The test server is configured via a config/test.yaml file:

server:
  port: 3000
  database_url: postgres://test:test@localhost:5433/myapp_test
  cache_url: redis://localhost:6380
  logging:
    level: debug

When tests fail, dev test prints a summary, including a link to a test-report.html file in ./coverage/index.html. The most common failure: tests expect the database to be seeded, but the test database is empty. The fix is to add a setup script in dev.yaml:

setup:
  - command: "psql -U test -d myapp_test -c 'CREATE TABLE users (id serial PRIMARY KEY, name text);'"
  - command: "psql -U test -d myapp_test -c 'INSERT INTO users (name) VALUES ('Alice'), ('Bob');'"

These commands run before test execution, and dev test exits with a non-zero code if any setup step fails.

Environment Management: From Dev to Production

A new engineer must be able to switch between environments: dev, staging, and prod. dev env manages this:

dev env list
# Output:
# * dev
#   staging
#   prod

dev env use staging

This command updates the dev.yaml file to use the staging configuration, then restarts all services:

docker-compose -f docker-compose.staging.yaml up -d --wait

The --wait flag ensures all services are ready before the command returns. The most subtle failure: dev env use staging does not restart the api service. The fix is to add restart: unless-stopped in the staging.yaml file:

services:
  api:
    restart: unless-stopped
    # ...

When a developer runs dev env use prod, they expect the application to use prod configuration and connect to production services. But the api service still uses dev database. The root cause: dev.yaml defines DATABASE_URL, but docker-compose does not apply it unless the env key is explicitly set.

The solution is to use env_file in dev.yaml:

services:
  api:
    env_file:
      - .env.dev
      - .env.staging
      - .env.prod
    environment:
      DATABASE_URL: ${DATABASE_URL}

The .env.prod file contains:

DATABASE_URL=postgres://prod:[email protected]:5432/myapp_prod
CACHE_URL=redis://prod-cache.your-org.com:6379
LOG_LEVEL=info

Now, dev env use prod correctly configures the environment.

Configuration: How to Get It Right

The most common mistake: dev.yaml is not checked into version control. The result: a new engineer checks out the code, runs dev setup, but gets a connection refused error to localhost:5432 because the db service is not running. The fix: dev setup must check for docker-compose.dev.yaml and fail if it is missing.

The second most common: dev.yaml is present but has a typo in a key. For example, services.api.env.POSTGRES_USER instead of POSTGRES_USER. The fix: use yaml validation in CI to catch typos:

# .github/workflows/dev-config.yaml
name: Validate dev.yaml
on: [push, pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Validate dev.yaml
        run: |
          docker run --rm -v $(pwd):/app \
            -w /app \
            yq -t -e -r 'dev.yaml' || exit 1

This job runs yq to validate the dev.yaml file against a schema defined in dev.schema.yaml. A missing key or incorrect type fails the pipeline.

Troubleshooting: When It Breaks

When a new engineer reports that dev run api fails with Error: Cannot find module 'express', the most likely causes:

  1. node_modules not installed: npm install missing in dev run.
  2. package.json not in the container: dev.yaml does not mount the package.json file.
  3. NODE_PATH not set: the node binary cannot find express.

The fix is to add volumes and env to dev.yaml:

services:
  api:
    volumes:
      - ./package.json:/app/package.json
      - ./node_modules:/app/node_modules
    env:
      NODE_PATH: "/app/node_modules"

The most silent failure: dev run api starts the server, but curl localhost:8080 returns 404 Not Found. The cause: the api service starts with --config config/dev.yaml, but the file is not mounted into the container. The fix: ensure config/dev.yaml is in the volumes list.

Summary: The Developer Experience Engineering Checklist

When all these are in place, the new engineer is productive on day one—not just onboarding, but *engineering*.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.