diff --git a/docs/README.md b/docs/README.md index a2d2f12..e4940dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ These are the standards that must be followed for all software development under - [Development languages](./standards/development_language_standards.md) - [JavaScript standards](./standards/javascript_standards.md) - [C# coding standards](./standards/csharp_coding_standards.md) +- [Local development standards](./standards/local_development_standards.md) - [Logging standards](./standards/logging_standards.md) - [Mobile application standards](./standards/mobile_app_standards.md) - [Node.js standards](./standards/node_standards.md) @@ -84,6 +85,7 @@ These guides provide additional support for meeting and working with the standar - [GitHub Advanced Security](./guides/github_advanced_security.md) - [Java auto-format with Eclipse](./guides/java_auto_format_eclipse.md) - [Kubernetes](./guides/kubernetes.md) +- [Local development patterns](./guides/local_development_patterns.md) - [Managing Application Credentials](./guides/application_credentials.md) - [Mobile application guidance](./guides/mobile_app_guidance.md) - [New starters](./guides/new_starters.md) diff --git a/docs/guides/docker_guidance.md b/docs/guides/docker_guidance.md index c5bddd7..3094385 100644 --- a/docs/guides/docker_guidance.md +++ b/docs/guides/docker_guidance.md @@ -1,332 +1,381 @@ -# Docker guidance +# Docker -A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably across multiple environments. Docker is a tool to build and run these containers. +A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably across multiple environments. Docker is a tool to build and run these containers. + +This guide covers how to build and run containers well: image construction, security, and Compose configuration. It applies wherever containers run, including CI. + +It does not tell you whether to run your service in a container while developing it. That is a separate decision, and the [local development patterns guide](local_development_patterns.md) sets out the options and our recommended defaults. The rules any approach has to satisfy are in the [local development standards](../standards/local_development_standards.md). ## More information + [Docker introduction on docker.com](https://www.docker.com/resources/what-container) ## Terminology -`Dockerfile` - set of instructions for building a docker image -`Image` - a constructed set of layered docker instructions -`Container` - a running instance of an image + +`Dockerfile` - set of instructions for building a Docker image +`Image` - a constructed set of layered Docker instructions +`Container` - a running instance of an image +`Compose file` - a `compose.yaml` file describing how to build and run one or more services + +> **Docker Compose v1 has reached end of life.** Use the Compose v2 plugin, invoked as `docker compose` (with a space), not the standalone `docker-compose` binary. The canonical Compose filename is `compose.yaml`, and the top-level `version:` key is obsolete and should be omitted. + +## Base images + +Defra publishes hardened base images that provide a non-root user, CA certificates, and the debugging tooling needed for local development. + +These images are scanned for vulnerabilities daily using [Trivy](https://github.com/aquasecurity/trivy) and [Grype](https://github.com/anchore/grype). + +Build on these rather than the raw upstream images: + +- [defra-docker-node](https://github.com/DEFRA/defra-docker-node) - Node.js (`defradigital/node` and `defradigital/node-development`) +- [defra-docker-dotnetcore](https://github.com/DEFRA/defra-docker-dotnetcore) - .NET (`defradigital/dotnetcore` and `defradigital/dotnetcore-development`) + +Always pin the base image version. Never depend on `latest`, as an unpinned tag makes builds non-reproducible and can pull in unexpected changes. ## Multi stage builds -Dockerfiles should implement multi stage builds to allow different build stages to be targeted for specific purposes. For example, a final production image does not need all the unit test files and a unit test running image would use a different running command than the application. -Below is an example multi stage build which is intended to use the Defra Node.js base image. +Dockerfiles should implement multi stage builds so that different stages can be targeted for specific purposes. A production image does not need dev dependencies, test files, or a watch command, whereas a development image does. -``` -ARG PARENT_VERSION=1.0.0-node12.16.0 +The example below uses the Defra Node.js base image. It has two stages: `development` (used locally, with dev dependencies and hot reload) and `production` (the lean deployable artifact). + +```dockerfile +ARG PARENT_VERSION=3.1.1-node24.18.0 ARG PORT=3000 ARG PORT_DEBUG=9229 -# Development FROM defradigital/node-development:${PARENT_VERSION} AS development -ARG PARENT_VERSION -ARG REGISTRY -LABEL uk.gov.defra.parent-image=defradigital/node-development:${PARENT_VERSION} + +ENV TZ="Europe/London" + ARG PORT -ENV PORT ${PORT} ARG PORT_DEBUG +ENV PORT=${PORT} EXPOSE ${PORT} ${PORT_DEBUG} -COPY --chown=node:node package*.json ./ -RUN npm install -COPY --chown=node:node app/ ./app/ -RUN npm run build -CMD [ "npm", "run", "start:watch" ] -# Production -FROM defradigital/node:${PARENT_VERSION} AS production -ARG PARENT_VERSION -ARG REGISTRY -LABEL uk.gov.defra.parent-image=defradigital/node:${PARENT_VERSION} -ARG PORT -ENV PORT ${PORT} -EXPOSE ${PORT} -COPY --from=development /home/node/app/ ./app/ -COPY --from=development /home/node/package*.json ./ +COPY --chown=node:node package*.json ./ RUN npm ci -CMD [ "node", "app" ] -``` +COPY --chown=node:node . . -## Docker Compose guidance +CMD [ "npm", "run", "dev" ] -### Use override files to reduce duplication -Additional settings can be applied to a docker compose file by using override files. - -Override files can be applied by listing the files after the `docker-compose` command with the `-f` parameter, i.e. - -`docker-compose -f docker-compose.yaml -f docker-compose.override.yaml up` - -Note that the above is equivalent to running the command: - -`docker-compose up` +FROM defradigital/node:${PARENT_VERSION} AS production -as calling `docker-compose` without specifying any files will run `docker-compose` with any available `docker-compose.yaml` and `docker-compose.override.yaml` files in the executing directory. +ENV TZ="Europe/London" -Note however that: +USER root -`docker-compose up -f docker-compose.yaml` +COPY --from=development --chown=root:root /home/node/package*.json ./ +COPY --from=development --chown=root:root /home/node/src/ ./src/ -will **not** apply the docker `docker-compose.override.yaml` file, only the file specified. +RUN npm ci --omit=dev -One use case is for running tests in CI - common settings can be put into the base `docker-compose.yaml` file, while changes to the command and containers needed in local development can be placed in override files. +# Remove write permissions from application files +RUN chmod -R a-w /home/node -The below example demonstrates changing the command and container name for testing: +USER node -`docker-compose.yaml` +ARG PORT +ENV PORT=${PORT} +EXPOSE ${PORT} +CMD [ "node", "." ] ``` -version: '3.4' -services: - ffc-demo-service: - build: . - image: ffc-demo-service - container_name: ffc-demo-service - environment: - DEMO_API: http://demo-api -volumes: - node_modules: {} +Notes on this example: -``` +- **Pin the base image** with `ARG PARENT_VERSION` and use the same version for both stages. `3.1.1-node24.18.0` is the current Node 24 (LTS) Defra base at the time of writing. Check [defra-docker-node](https://github.com/DEFRA/defra-docker-node) for the latest. +- **Use `npm ci`, not `npm install`.** `npm ci` installs exactly what is in `package-lock.json`, giving reproducible builds. Use `npm ci --omit=dev` in production to exclude dev dependencies. +- **Set `ENV TZ`** so container timestamps match the expected timezone. -`docker-compose.test.yaml` -``` -version: '3.4' -services: - ffc-demo-service: - command: npm run test - container_name: ffc-demo-service-test -``` +## Security best practices -The tests can be run by providing the `docker-compose.test.yaml` file with a `-f` parameter: +### Run as a non-root user -`docker-compose up -f docker-compose.yaml -f docker-compose.test.yaml` +Containers must run as a non-root user. The Defra base images provide a `node` user (and a `dotnet` user for .NET). Switch to it with `USER node` before the container's `CMD` runs so the process has the least privilege it needs. -It is also recommended not to expose any ports through Docker Compose used in CI as they may conflict with other ports already in use in the build agent. +### File ownership and write permissions -Further documentation on docker-compose can be found at https://docs.docker.com/compose/reference/overview/#specifying-multiple-compose-files. +Security scanners such as SonarQube flag application files that the running user can write to (see [SonarSource rule S6504](https://rules.sonarsource.com/docker/type/Security%20Hotspot/RSPEC-6504/)). A running process should not be able to modify its own application code, as this reduces the impact of a compromised process. -### Use projects to provide unique volumes and networks -To avoid conflicts when running different permutations of docker files, projects should be specified to segregate the volumes and networks. +`COPY --chown=node:node` makes the running `node` user the owner, which grants write access. `COPY` also preserves the source file permissions, so changing ownership alone does not reliably remove write access. To guarantee read-only application files in the **production** stage: -This can be achieved with the `-p` switch when calling docker compose on the command line. +1. Copy files as `root` with `COPY --chown=root:root`. +2. Explicitly remove write permissions with `RUN chmod -R a-w /home/node`. +3. Switch to the non-root user with `USER node`. - i.e. to start the service +Because the `node` user neither owns the files nor has write permission, the application code is read-only at runtime. This is preferable to `chmod 755`, which still leaves the owner able to write. -`docker-compose -p ffc-demo-service -f docker-compose.yaml up` +```dockerfile +COPY --from=development --chown=root:root /home/node/src/ ./src/ +RUN npm ci --omit=dev +RUN chmod -R a-w /home/node +USER node +``` -and to run the tests +> **Apply read-only ownership to the production stage only.** In the `development` stage keep `COPY --chown=node:node` and do **not** run `chmod -R a-w`. Watch mode, tests, and coverage reports all need to write to the container filesystem, so a read-only development image breaks the inner loop. Scanners may flag the development stage for the missing `chmod`; that is acceptable for a local-only image. -`docker-compose -p ffc-demo-service-test -f docker-compose.yaml -f docker-compose.test.yaml up` +> **Some processes legitimately need to write at runtime.** A service might write to a mounted `tmp` directory or a cache such as `node_modules/.cache`. Where this is required, define and secure those specific writable locations (for example a dedicated mounted volume) rather than making the whole application tree writable. Consider whether your service has this need before applying blanket read-only permissions. -### Use environment variables to guarantee unique projects and containers -When running through CI, a combination of the `-p` switch and environment variables can be used to ensure each build and test has unique project and container names. This will prevent conflicts with other build pipelines when using tools such as a single node Jenkins. +## Docker Compose -For example using Jenkins, the following compose files can be started via: +Use a single `compose.yaml` per repository. Older services may split configuration across many files (`docker-compose.override.yaml`, `docker-compose.test.yaml`, `docker-compose.test.watch.yaml`, and so on), which drift out of sync and are hard to reason about. Compose v2 profiles remove the need for most of these. -`docker-compose -p ffc-demo-service-$PR_NUMBER-$BUILD_NUMBER -f docker-compose.yaml up` +### One file with profiles -and tested with +Put the application service behind a profile so that `docker compose up` starts only the backing dependencies, and the full stack starts on demand: -`docker-compose -p ffc-demo-service-test-$PR_NUMBER-$BUILD_NUMBER -f docker-compose.yaml -f docker-compose.test.yaml up` +```yaml +services: + my-service: + profiles: ["app"] + build: + context: . + target: development + ports: + - "3000:3000" + - "9229:9229" + env_file: + - .env + environment: + REDIS_HOST: redis + depends_on: + redis: + condition: service_healthy + volumes: + - ./src:/home/node/src + networks: + - my-network -using `PR_NUMBER` and `BUILD_NUMBER` environment variables to isolate build tasks. + redis: + image: redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - my-network + +networks: + my-network: + driver: bridge + name: my-network +``` -`docker-compose.yaml` +Start dependencies only, or the whole stack: +```bash +docker compose up -d # start dependencies only (Redis here) +docker compose --profile app up -d # start dependencies and the application ``` -version: '3.4' -services: - ffc-demo-service: - build: . - image: ffc-demo-service - container_name: ffc-demo-service-${PR_NUMBER}-${BUILD_NUMBER} -volumes: - node_modules: {} -``` +Gating the app behind `profiles: ["app"]` supports the common local workflow of running the app itself on the host (or in your IDE) while its dependencies run in containers, and still lets an orchestration repo bring up the whole stack with `--profile app`. -`docker-compose.test.yaml` -``` -version: '3.4' -services: - ffc-demo-service: - command: npm run test - container_name: ffc-demo-service-test-${PR_NUMBER}-${BUILD_NUMBER} -``` +### Layer environment variables -### Composing multiple repositories for local development -For scenarios where multiple containers need to be created across multiple repositories, it might be advantageous to create a "development" repo. +Keep one set of developer values in `.env` and override only what has to change inside a container. Compose reads `env_file` first, then applies `environment`, which takes precedence: -The development repository would: +```yaml + env_file: + - .env # every developer-supplied value, e.g. REDIS_HOST=localhost + environment: + REDIS_HOST: redis # overridden, because inside Docker the dependency is on its service name +``` -- clone all necessary repositories -- builds images from Dockerfiles in each repository by referencing Docker Compose files in those repositories -- run containers based on those images in a single Docker network by referencing Docker Compose files in those repositories -- run single containers for any shared dependencies across repositories such as message queues or databases +In practice only a handful of values need overriding, almost always hostnames and ports, because the same dependency is reached at `localhost` from the host and at its Compose service name from inside the network. -To facilitate this, each repository with a potentially shared dependency will need its Docker Compose override files to be setup in such a way that dependency containers can be isolated. This will allow those repository services to run both in isolation and as part of wider service depending on development needs. +The same `.env` serves both modes. When the application runs on the host, its runtime loads the file directly rather than Compose loading it, so there is a single place to set a value regardless of where the application runs. -For example, let's say we have two repositories, **ServiceA** and **ServiceB**. **ServiceA** communicates with **ServiceB** via an ActiveMQ message queue. **ServiceB** has a PostgreSQL database. +This removes the old pattern of duplicating every variable across a base file and an override file. Keep `.env` out of source control and out of images by listing it in both `.gitignore` and `.dockerignore`, and commit a `.env.example` instead. -**ServiceA**'s Docker Compose files could be structured as follows. +### Health checks and start ordering -`docker-compose.yaml` - builds image and runs **ServiceA** -`docker-compose.override.yaml` - runs Artemis ActiveMQ container -`docker-compose.link.yaml` - runs **ServiceA** in a named Docker network +Give each dependency a `healthcheck` and make the app `depends_on` it with `condition: service_healthy`. Without this, the app can start before the dependency is ready and fail to connect: -**ServiceB**'s Docker Compose files could be structured as follows. +```yaml + postgres: + image: postgres:16.6 + environment: + POSTGRES_DB: my_database + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d my_database"] + interval: 10s + timeout: 10s + retries: 5 +``` -`docker-compose.yaml` - builds image and runs **ServiceB** and PostgreSQL container -`docker-compose.override.yaml` - runs Artemis ActiveMQ container -`docker-compose.link.yaml` - runs **ServiceB** in a named Docker network +### Set image and container names -**ServiceA** and **ServiceB** can be run in isolation by running the following commands in each repository. +If you do not set an image or container name, Compose derives one from the project and service names, which can be unpredictable. Set them explicitly where you need to reference the container later: -`docker-compose build` -`docker-compose up` +```yaml +services: + my-service: + image: my-service + container_name: my-service +``` -The development repository would contain the following. +### Isolate projects to avoid collisions -`docker-compose.yaml` - runs Artemis ActiveMQ container in named Docker network +Compose derives network, volume and container names from the project name, which defaults to the directory name. On a build agent running several pipelines, or a machine running two branches of the same service, that causes collisions. -A script which would run the following commands: +Set the project name explicitly with `-p` so each run gets its own network and volumes: +```bash +docker compose -p my-service-${PR_NUMBER}-${BUILD_NUMBER} up -d ``` -if [ -z "$(docker network ls --filter name=^NETWORK_NAME$ --format={{.Name}})" ]; then - docker network create NETWORK_NAME -fi -docker-compose up -docker-compose -f path/to/ServiceA/docker-compose.yaml -f path/to/ServiceA/docker-compose.link.yaml up --detach -docker-compose -f path/to/ServiceB/docker-compose.yaml -f path/to/ServiceB/docker-compose.link.yaml up --detach + +Use whatever unique values your CI platform provides. Container names interpolate the same variables if you set them explicitly: + +```yaml +services: + my-service: + container_name: my-service-${PR_NUMBER}-${BUILD_NUMBER} ``` -#### Avoiding docker-compose.yaml in the development repository -If it is preferred to avoid the need for an additional `docker-compose.yaml` file in the development repository itself, an alternative approach would be to explicity declare the shared resources are not started in subsequent `override` files in the start up script. +### Avoid port conflicts -For example: +When running multiple services locally, each must bind to a unique host port. Map container ports to different host ports per service, and do the same for dependency containers: -``` -if [ -z "$(docker network ls --filter name=^NETWORK_NAME$ --format={{.Name}})" ]; then - docker network create NETWORK_NAME -fi -docker-compose up -docker-compose -f path/to/ServiceA/docker-compose.yaml -f path/to/ServiceA/docker-compose.override.yaml -f path/to/ServiceA/docker-compose.link.yaml up --detach -docker-compose -f path/to/ServiceB/docker-compose.yaml -f path/to/ServiceB/docker-compose.override.yaml -f path/to/ServiceB/docker-compose.link.yaml up --detach --scale SERVICE_NAME=0 +```yaml +# service 1 +ports: + - "3000:3000" + - "9229:9229" + +# service 2 +ports: + - "3001:3000" + - "9230:9229" ``` -### Binding volumes to container -To aide local development, the local workspace can be bound to a Docker volume. This allows code changes to be automatically picked up within the container without the need to rebuild the image or restart the container. +Do not expose ports on containers used only in CI, as they may conflict with ports already in use on the build agent. -To best support this, workspaces should be structured so it is simple to determine which files should be bound to Docker volumes as it would not be appropriate to bind everything. For example, it would not be beneficial to bind `node_modules` or a `README`. +### Bind mount source for reload -Example of Docker compose file with volume binding. +Where the application itself runs in a container during development, mount the source you want watched so changes are picked up without a rebuild: -``` +```yaml volumes: - - ./app/:/home/node/app/ - - ./test/:/home/node/test/ - - ./test-output/:/home/node/test-output/ - - ./package.json:/home/node/package.json + - ./src:/home/node/src ``` -Changes to any of the directories listed above would automatically be picked up in the running container. +Bind only what needs watching. Do not bind `node_modules`, because the host and the image can hold different platform binaries, and there is no value in binding files such as a `README`. -Binding also allows developers to take advantage of file watching in testing applications. Changes made to code locally will automatically be reflected in the running container supporting a TDD approach. +A rebuild is still needed when dependencies change. That is the main cost of running the application in a container, and it is worth being upfront about it when choosing an approach. -### .dockerignore -A `.dockerignore` file is a way of preventing local files being copied into an image during build. +### Preserving database volumes -For example, if a repository contains the following files. +Integration tests that run against a containerised database write and delete data. To keep test data separate from local development data, declare the persistent volume only where you want persistence rather than in a shared base definition. For most repositories, prefer [Testcontainers](#running-tests) for integration tests, which gives each run a fresh, isolated database with no volume management at all. -``` -app/index.js -app/config.js -node_modules -index.js -README.md -LICENCE -Dockerfile -``` - -The `Dockerfile` in this repository includes the following layer which would copy all local files to the container. - -``` -COPY . . -``` +### .dockerignore -When the image is built then all files in the repository are copied to the image. In this scenario, it is not ideal for performance and disk space reasons to copy the `node_modules`, `LICENCE`, `Dockerfile` or `README.md` to the image. +A `.dockerignore` file prevents local files being copied into an image during build. This keeps images small and avoids copying artifacts such as `node_modules`, local `.env` files, and test files. -To prevent this a `.dockerignore` file should be added with the following content. +For a typical Node.js service: ``` node_modules Dockerfile +.dockerignore +.git +.env +coverage +**/*.test.js LICENCE README.md ``` -### Container and image names using Docker Compose -If an image name or container name is not specified in a Docker Compose file, then Docker Compose will determine it's own based on the service name. This can result in duplication in the name and unpredictabilty in futher container interaction. +## Running tests -#### Set image and container name +For integration tests that need real infrastructure, prefer [Testcontainers](https://testcontainers.com/) over a test-specific Compose file. The test process starts and stops the container itself, so each run gets a fresh, isolated dependency and the same code path runs locally and in CI. There are no `docker-compose.test*.yaml` files to keep in sync and no shared volumes to reset. -``` -version: '3.7' -services: - my-service: - image: my-service - container_name: my-service -``` +Tests need a running Docker daemon, but do not need `docker compose up` first. -### Preserving database volumes during test runs -In many scenarios it is beneficial to utilise Docker to run local integration tests against a containerised dependency such as a database or message broker. +See the [local development patterns guide](local_development_patterns.md) for the full pattern, including container lifecycle, database migrations and wait strategies. -These tests would typically write and delete data during test execution. In order to prevent this impacting on local development data and still avoid duplication in Docker Compose definitions, volumes should be declared separate to the database definition. +## Debugging in VS Code -For example, if you have the following Docker Compose files +Where the application runs on the host, debug it directly with a normal launch configuration. Where you need to debug a process running inside a container, use an attach configuration. -- `docker-compose.yaml` - base definition used in all scenarios -- `docker-compose.override.yaml` - applied when running locally only -- `docker-compose.test.yaml` - applied when running tests only +Commit debug configurations to `.vscode/launch.json` so they work from a clean clone. Working out an attach configuration individually is the main reason developers stop using a debugger on container-first services. -Then using a Postgres image as an example each definition should contain the following. +### Attach to a Node process in a running container -#### docker-compose.yaml -``` -version: '3.7' -services: - my-postgres-service: - image: postgres:11.4-alpine - environment: - POSTGRES_DB: my_database - POSTGRES_PASSWORD: postgres - POSTGRES_USERNAME: postgres +The container must run the app with the inspector enabled and bound to all interfaces (`node --inspect=0.0.0.0`) on the debug port exposed in the Compose file. Bound to `127.0.0.1`, it will not accept a connection from the host. + +```json +{ + "name": "Docker: Attach", + "type": "node", + "request": "attach", + "restart": true, + "port": 9229, + "localRoot": "${workspaceFolder}", + "remoteRoot": "/home/node", + "skipFiles": [ + "/**", + "**/node_modules/**" + ] +} ``` -#### docker-compose.override.yaml +`restart: true` reattaches the debugger when watch mode restarts the app. `localRoot` and `remoteRoot` map a breakpoint in the editor to a line inside the container; get them wrong and breakpoints silently never bind. + +When running several services together, give each a unique host debug port (for example 9229, 9230, 9231) mapped to the container's inspector port, so you can attach to more than one at a time. + +## Debugging .NET in a Linux container + +.NET services running in Linux containers are debugged with the `vsdbg` remote debugger. `vsdbg` is not part of the .NET SDK, so it must be present in the image. The Defra .NET development base image ([defra-docker-dotnetcore](https://github.com/DEFRA/defra-docker-dotnetcore)) already installs it (at `/vsdbg`), so services built on `defradigital/dotnetcore-development` do not need to add it. This remains the case for .NET 10. If you build on the plain Microsoft SDK image instead, install it yourself in the development stage: + +```dockerfile +ADD https://aka.ms/getvsdbgsh /tmp/getvsdbgsh +RUN /bin/sh /tmp/getvsdbgsh -v latest -l /vsdbg && rm /tmp/getvsdbgsh ``` -version: '3.7' -services: - ffc-demo-claim-postgres: - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data -volumes: - postgres_data: {} +### VS Code + +```json +{ + "name": ".NET Core Docker Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickRemoteProcess}", + "pipeTransport": { + "pipeProgram": "docker", + "pipeArgs": ["exec", "-i", "my-service-container"], + "debuggerPath": "/vsdbg/vsdbg", + "pipeCwd": "${workspaceRoot}", + "quoteArgs": false + }, + "sourceFileMap": { + "/home/dotnet": "${workspaceFolder}" + } +} ``` -Then volume and port bindings are only used during local development and any local tests runs will not impact development data. +### Visual Studio -### Windows Git Bash +Visual Studio does not integrate with the WSL filesystem, so WSL users must clone the repository in Windows to debug using Visual Studio. Set the following git configuration to preserve line endings: -There is an issue where Git Bash may not correctly interpret volume paths when running Docker Compose on Windows. +```bash +git config --global core.autocrlf input +``` + +1. Start the container with `docker compose up --build`. +2. In Visual Studio, select `Debug -> Attach to process`. +3. Select `Docker (Linux Container)` for connection type. +4. Enter the container name in connection target. +5. Select the process matching the running application. +6. Select `Managed (.NET Core for Unix)` code type. -To avoid this issue, the following snippet should be added to the `.bashrc` file in the home directory of the user running Git Bash. +## Windows Git Bash + +Git Bash may not correctly interpret volume paths when running Docker Compose on Windows. To avoid this, add the following to the `.bashrc` in the home directory of the user running Git Bash: ```bash # --- Make Docker work nicely in Git Bash --- @@ -350,3 +399,13 @@ docker() { fi } ``` + +## References + +- [local development patterns](local_development_patterns.md) - choosing how to run and test a service locally +- [local development standards](../standards/local_development_standards.md) - the rules any local setup must meet +- [container standards](../standards/container_standards.md) - Defra container requirements +- [defra-docker-node](https://github.com/DEFRA/defra-docker-node) - Defra Node.js base images +- [defra-docker-dotnetcore](https://github.com/DEFRA/defra-docker-dotnetcore) - Defra .NET base images +- [Docker Compose documentation](https://docs.docker.com/compose/) +- [Testcontainers](https://testcontainers.com/) diff --git a/docs/guides/local_development_patterns.md b/docs/guides/local_development_patterns.md new file mode 100644 index 0000000..d878c7b --- /dev/null +++ b/docs/guides/local_development_patterns.md @@ -0,0 +1,672 @@ +# Local development patterns + +This guide helps you decide how to run a service and its tests on a developer machine, and shows you how to set up each option with as little friction as possible. + +There is no single right answer for every service, so the guide sets out the options, explains when each one is the better fit, and gives you a working implementation for whichever one you choose. Where we do have a preferred default, we say so up front: + +- **run the application on the host, with its dependencies in containers** +- **run tests on the host, using in-process fakes for unit tests and testcontainers for integration tests** + +Start from these defaults unless your service has a specific reason not to. The rest of the guide explains what those reasons look like, and how to implement each option well. + +The examples use Node.js because it is our primary and most common tech stack, but the patterns generalise: the compose profile, the orchestration workspace and the testcontainers lifecycle all work the same way in other technologies. + +For the rules that apply whichever option you pick, see the [local development standards](../standards/local_development_standards.md). + +## Two key decisions + +There are two key conscious decisions teams should make that dictate the local development experience: + +1. **How does the application run?** On the host, or in a container. And where do its dependencies come from. +2. **How do the tests run?** On the host, or in a container. And where do their dependencies come from. + +## Decision 1: how the application runs + +| Approach | What it buys you | What it costs you | Best fit | +| --- | --- | --- | --- | +| **Native app, containerised dependencies** (recommended default) | Fast reload, a working native debugger, and real dependency behaviour. The most common compromise, for good reason. | Two worlds to keep in sync. Host runtime version can drift from the image. Docker is still required. | Services with real infrastructure dependencies where iteration speed matters. | +| **Native app, dependencies installed on the machine** | No Docker at all. Lowest resource use on a constrained laptop. | "Works on my machine" comes back. Version drift stays invisible until something breaks. Onboarding becomes a page of install steps. | Services with a single dependency, or teams with a mature and scripted local install. | +| **Native app, dependencies stubbed in process** | The fastest possible loop. No daemon, no images, no ports. | A fidelity gap. You are testing against your belief about the dependency. Stubs drift silently. | Frontends, thin API layers, anything where the dependency is someone else's HTTP contract. | +| **Native app, no dependencies** | Nothing to explain. One command and you are working. | Only honest for genuinely dependency-free services. Frequently claimed, more rarely true. | Libraries, pure transformation services, static frontends. | +| **App and dependencies in containers** | The highest environment parity. One command. Identical for everyone, including CI. | The slowest inner loop. Debugging needs an attach configuration, and most developers give up on it. Rebuilds on dependency changes. | Services with many or awkward dependencies, and teams who value parity over iteration speed. | +| **Virtual machine, Codespaces or a shared instance** | Solves a constraint the other options cannot, usually a corporate device or licensing restriction. | Invisible to everyone else. No shared tooling or troubleshooting. Rarely documented. | Where it is genuinely the only option. | + +### The debugging trade + +Container-first is usually chosen for onboarding speed and consistency, and the cost is almost always debugging. Developers who only ever run their service in a container are far more likely to fall back on logs and print statements than to attach a step debugger, even though remote attach to the inspector port is documented and works. + +That cost does not show up on day one. It shows up every day after that, in the loop you run many times a day. + +If you choose container-first, choose it knowing this, and make the attach configuration part of the repository rather than something each developer works out alone. + +## Decision 2: how tests run + +| Approach | What it buys you | What it costs you | Best fit | +| --- | --- | --- | --- | +| **Native tests, in-process fakes** (recommended default for unit tests) | No Docker. Millisecond feedback. Trivial to run a single test. Works on any machine. | Only as good as the fake. In-process fakes exist for very few dependencies. Passing tests can prove less than they appear to. | Unit tests. | +| **Native tests, testcontainers** (recommended default for integration tests) | The container lifecycle is owned by the test code, so there is no "did you start compose first?". Clean state on every run. Already the documented approach in .NET, Java and Python. | Slower per run. Containers are invisible when something fails, so diagnosis is harder. Needs a Docker daemon. | Integration tests that need isolation and reproducibility, locally and in CI. | +| **Native tests, compose dependencies** | Real dependency behaviour with a native runner, watch mode and a working debugger. | The compose stack has to be up first. State leaks between runs. Tests can pass against a stale container. | Integration tests against a database you already run in compose. | +| **Native tests, dependencies installed on the machine** | The fastest integration tests available. No container overhead. | Every developer's machine is a different test environment. The worst reproducibility of any option. | Rarely the right answer. Usually a legacy position. | +| **Native tests, no dependencies** | Nothing to arrange. Genuinely instant. | Only honest for pure logic. Often a sign that integration tests are missing rather than unnecessary. | Pure functions, formatters, validators. | +| **Tests and dependencies in containers** | Exact parity with CI. The same thing runs locally and in the pipeline. | Container start cost on every run. Running one test means a container round trip. Watch mode needs its own compose overlay, and debugging needs another. | Teams where local and CI divergence has actually burned them. | +| **Tests run in CI, using CI dependencies** | Zero local setup. The pipeline owns the dependencies. | The feedback loop is a push and a wait. Failures are diagnosed from logs. It encourages people not to run tests before pushing. | Expensive or licence-restricted dependencies. | +| **A deliberate mix** | Honest. Different test tiers legitimately want different things. | Almost nobody writes down what the mix is. | Most real services, if we are candid about it. | + +### A mix is a legitimate answer + +Many teams genuinely mix strategies: unit tests in process, integration tests in containers, journey tests against a deployed environment. That is not a failure to decide. It is usually the right answer for a service of any size. + +What matters is that the mix is deliberate and written down, not the accidental result of three people solving three problems on three different days. + +### Watch for the fidelity gap + +A common combination is a service that starts a real database to develop against, then tests against a fake one. + +Sometimes that is a sensible trade. Often it is nobody's decision. + +The cause is usually tooling. In-process fakes are readily available for MongoDB, so teams using MongoDB get fast, dependency-free tests almost by accident. There is no equivalent shipped for PostgreSQL, SQS, S3 or Service Bus, so teams using those either reach for testcontainers themselves or defer the question to a deployed environment. + +If your unit tests use an in-process fake, ask what proves the real dependency behaves the way you think. If nothing does, that is the gap testcontainers fills. + +## Choosing an approach + +Work through these questions. They are prompts for a team conversation, not a flowchart with one correct exit. + +**Does the service have stateful infrastructure dependencies, such as a database, cache or message broker?** + +- No, and it genuinely calls nothing external. Run it natively with no dependencies. Test it natively with no dependencies. +- No, but it calls other HTTP services. Run it natively with those calls stubbed in process. Add contract tests so the stubs cannot drift silently. +- Yes. Keep reading. + +**Do you need a step debugger in your inner loop?** + +- Yes, and most people do. Run the application natively with dependencies in containers. +- No, and you are genuinely happy debugging from logs. Container-first becomes viable. + +**Has divergence between local and CI actually caused you problems?** + +- Yes, repeatedly, and you can name the incidents. Container-first buys you real parity and the debugging cost may be worth paying. +- No. You are paying for insurance against a risk you have not experienced. + +**Can your dependencies run in a container or an emulator?** + +- Yes. Use containers. This is close to universal practice for infrastructure dependencies, and for good reason. +- No, because of licensing, cost or a cloud service with no emulator. Consider running those specific tests in CI, and stub the dependency locally. Do not let one awkward dependency dictate the approach for everything else. + +**Does your team routinely run several services together?** + +- Yes. Add an orchestration repository and a shared workspace, so "run everything" is one command regardless of which approach each service uses. +- No. Keep each repository self-contained. + +**Whatever you choose, can a new developer get the service running from a clean clone using the commands in the README?** + +If not, fix that before anything else. It is the one thing the standards have always required. + +## Native application with containerised dependencies + +This is the recommended default. The application runs on the host with hot reload and a native debugger. Its dependencies run in containers. The same compose file can also run the application itself when you need the full stack. + +### One compose file, with the app behind a profile + +Keep a single `compose.yaml` per repository. Put the application service behind a profile so it does not start by default. + +```yaml +services: + my-api: + profiles: ["app"] + build: + context: . + target: development + ports: + - "3001:3001" + - "9229:9229" + volumes: + - ./src:/home/node/src + env_file: + - .env + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + depends_on: + postgres: + condition: service_healthy + networks: + - my-network + + postgres: + image: postgres:16.6 + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ppp + POSTGRES_DB: my_api + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - my-network + +volumes: + postgres-data: + +networks: + my-network: + driver: bridge +``` + +That gives you two modes from one file: + +```bash +docker compose up -d # dependencies only, for host-native development +docker compose --profile app up -d # the full stack in containers +``` + +A single file that supports both modes is much easier to keep correct than a family of overlays that drift apart. + +### Two layers of environment configuration + +The application needs different hostnames depending on where it runs. On the host it talks to `localhost`. Inside a container it talks to the compose service name. + +Handle that with two layers rather than two files. A single `.env` holds every developer-supplied value. When the application runs on the host, the runtime loads that file directly. When it runs in a container, compose loads the same file through `env_file:`, then the `environment:` block overrides only the values that have to differ, which in practice is little more than hostnames and ports. + +That way there is one place to set a value, wherever the application ends up running. The [Docker guidance](docker_guidance.md) covers the compose syntax and precedence rules. + +Commit a `.env.example` with safe defaults and never commit `.env`. + +### The script surface + +Use the same script names in every repository so developers moving between services do not have to relearn them. + +| Script | Command | Purpose | +| --- | --- | --- | +| `dev` | `NODE_ENV=development node --env-file-if-exists=.env --watch --watch-path=./src src/index.js` | Run on the host with hot reload | +| `dev:debug` | as above, plus `--inspect` | Same, with the inspector on `127.0.0.1:9229` | +| `local` | `npm run services:up && npm run dev` | The one command a developer needs | +| `services:up` | `docker compose up -d` | Start dependencies only | +| `services:down` | `docker compose down` | Stop dependencies | +| `start` | `NODE_ENV=production node .` | Used inside the production image | +| `test` | `vitest run --coverage` | Unit and integration tests | +| `test:unit` | `vitest run --coverage --project unit` | Fast feedback | +| `test:integration` | `vitest run --coverage --project integration` | Real dependencies | +| `test:watch` | `vitest` | Test-driven inner loop | +| `docker:build` | `docker compose --profile app build` | Build the application image | +| `docker:dev` | `docker compose --profile app up` | Run the whole stack in containers | + +Two details matter. + +Use `--env-file-if-exists` rather than `--env-file`. The latter fails hard when the file is missing, which breaks every deployed environment where configuration comes from the platform instead of a file. + +Use the runtime's own file watcher rather than a separate watch dependency. `node --watch --watch-path=./src` removes a dependency and behaves consistently across operating systems. + +Prefix test scripts with `cross-env TZ=UTC` so date assertions behave the same on every machine. + +### Make shutdown fast in development + +If the service drains in-flight requests on shutdown, the timeout that protects production makes every hot reload feel slow. Use a short timeout in development and the full timeout in production. + +```javascript +const shutdownTimeout = config.get('isDevelopment') ? 1000 : 10000 +``` + +### VS Code configuration + +Commit `.vscode/launch.json` so debugging works from a clean clone. + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Dev: run server", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/src/index.js", + "runtimeArgs": [ + "--env-file-if-exists=.env", + "--watch", + "--watch-path=./src", + "--inspect" + ], + "env": { "NODE_ENV": "development" }, + "restart": true, + "console": "integratedTerminal" + }, + { + "name": "Debug current test", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/vitest", + "args": ["run", "--inspect", "--no-file-parallelism", "${relativeFile}"], + "env": { "TZ": "UTC", "NODE_ENV": "test" }, + "console": "integratedTerminal" + } + ] +} +``` + +Commit `.vscode/tasks.json` so the common commands are discoverable without reading the README. + +```json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Services: Up", + "type": "npm", + "script": "services:up", + "problemMatcher": [] + }, + { + "label": "Dev", + "type": "npm", + "script": "dev", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Test", + "type": "npm", + "script": "test", + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": [] + } + ] +} +``` + +## Integration tests with testcontainers + +Testcontainers starts the containers your tests need from inside the test process, and stops them when the run finishes. There is no separate stack to remember to start, no shared state between runs, and no set of compose test overlays to keep in step. + +### Split unit and integration tests into projects + +Unit tests should not pay the container cost. Split them, so a developer can run either tier alone. + +```javascript +import { defineConfig } from 'vitest/config' + +const sharedEnv = { + NODE_ENV: 'test' +} + +export default defineConfig({ + test: { + clearMocks: true, + coverage: { + include: ['src/**/*.js'], + exclude: ['**/test/**', 'coverage'], + reporter: ['lcov', 'text'] + }, + projects: [ + { + test: { + name: 'unit', + include: ['test/unit/**/*.test.js'], + clearMocks: true, + env: { + ...sharedEnv, + POSTGRES_HOST: 'postgres', + POSTGRES_PORT: '5432' + } + } + }, + { + test: { + name: 'integration', + include: ['test/integration/**/*.test.js'], + clearMocks: true, + globalSetup: ['./test/setup/global-db.js'], + env: sharedEnv + } + } + ] + } +}) +``` + +Pin the connection values in the unit project. Both projects can share a process, and without pinning, the dynamically mapped port set by the integration project can leak into the unit project and produce confusing failures. + +### Start the dependency in global setup + +Global setup runs once per test session, before any test. It starts the container, waits for it to be ready, writes the connection details into `process.env`, and returns a teardown function. + +```javascript +import { PostgreSqlContainer } from '@testcontainers/postgresql' +import { GenericContainer, Network, Wait } from 'testcontainers' + +export default async function setup () { + const network = await new Network().start() + + const postgres = await new PostgreSqlContainer('postgres:16.6') + .withNetwork(network) + .withNetworkAliases('postgres') + .withDatabase('my_api') + .withUsername('postgres') + .withPassword('ppp') + .start() + + const migrations = await new GenericContainer('liquibase/liquibase:4') + .withNetwork(network) + .withBindMounts([{ + source: `${process.cwd()}/changelog`, + target: '/liquibase/changelog' + }]) + .withCommand([ + '--url=jdbc:postgresql://postgres:5432/my_api', + '--username=postgres', + '--password=ppp', + '--changeLogFile=db.changelog.xml', + 'update' + ]) + .withWaitStrategy(Wait.forOneShotStartup()) + .start() + + process.env.POSTGRES_HOST = postgres.getHost() + process.env.POSTGRES_PORT = String(postgres.getMappedPort(5432)) + process.env.POSTGRES_USER = 'postgres' + process.env.POSTGRES_PASSWORD = 'ppp' + process.env.POSTGRES_DB = 'my_api' + + return async () => { + await migrations.stop() + await postgres.stop() + await network.stop() + } +} +``` + +Three things are happening here and they are the whole pattern. + +**Migrations run in a container on the same network.** The migration tool connects to `postgres:5432` using the network alias, not a mapped port, because it is inside the network. `Wait.forOneShotStartup()` handles a container that runs to completion and exits, rather than one that stays up. + +**Ports are mapped dynamically.** `getMappedPort(5432)` returns whatever high port Docker assigned on the host. Nothing is hardcoded, so parallel runs and busy machines do not collide. + +**Configuration is injected through the environment.** The application reads its configuration from environment variables at startup, so writing to `process.env` before any test imports the application is enough to point it at the test container. This is the reason environment-based configuration matters: it is what makes the whole pattern work without a test-specific code path. + +A cache or broker is usually simpler, with no network and no second container. + +```javascript +import { GenericContainer, Wait } from 'testcontainers' + +export default async function setup () { + const redis = await new GenericContainer('redis') + .withExposedPorts(6379) + .withWaitStrategy(Wait.forLogMessage('Ready to accept connections')) + .start() + + process.env.REDIS_HOST = redis.getHost() + process.env.REDIS_PORT = String(redis.getMappedPort(6379)) + + return async () => { + await redis.stop() + } +} +``` + +Choose the wait strategy deliberately. A log message, a health check or a port being open all mean different things, and picking the wrong one produces flaky tests that look like application bugs. + +### Debugging a test + +Run a single test file with parallelism disabled so the debugger can follow it. + +```bash +npx vitest run --inspect --no-file-parallelism test/integration/payments.test.js +``` + +This is the "Debug current test" configuration shown earlier. Being able to set a breakpoint in a failing integration test, with a real database behind it, is the main practical advantage of running tests on the host. + +## Running everything in containers + +Choose this when parity matters more than iteration speed: many awkward dependencies, a history of local and CI divergence, or a team that genuinely prefers one command over a fast debugger. + +Make the trade explicit in the README, and invest in the debugging setup, because that is where the cost lands. + +The mechanics are in the [Docker guidance](docker_guidance.md): the multi-stage image with a `development` target, bind mounting source for reload, isolating compose projects so parallel work does not collide, and the attach configuration needed to debug a process inside a container. The [container standards](../standards/container_standards.md) set out what a Defra image must do. + +Four things are worth calling out here, because they are specific to using containers as your inner loop rather than as a deployment artifact. + +### Commit the debug configuration + +This is the part that usually gets skipped, and it is why container-first developers stop using a debugger. + +Two pieces have to line up. The container must start the process with the inspector listening on all interfaces, not just loopback, or it will not accept a connection from the host. The attach configuration must then map the editor's workspace folder to the path inside the container, or breakpoints silently never bind. + +Neither is hard, but both are easy to get subtly wrong, and a developer who hits it once tends to fall back to logging rather than debug it. Work it out once and commit it, for every service in the repository. + +### Keep the development stage writable + +The production stage should remove write permissions from application files and run as a non-root user. Do not carry that into the development stage: watch mode, test runs and coverage reports all need to write to the container filesystem, and a read-only development image breaks the inner loop. + +### Keep database volumes out of the test teardown + +If tests and development share a compose project, tearing down after a test run destroys the database you were working with. Give the test run its own project name, or its own volume, so `docker compose down -v` after tests does not take your development data with it. + +### Expect a rebuild when dependencies change + +Source changes reach the container through the bind mount, but a lockfile change does not. This is the recurring cost of the approach, and the main thing that makes the container-first loop slower than a host-native one. + +## Orchestrating multiple services + +When a team owns several services that are usually run together, put the orchestration in one small repository rather than in each service's README. The pattern below is in use in Defra and works well. + +The orchestration repository is not deployable. It exists only to make local development straightforward. Each service repository stays fully self-contained and can still be run on its own. + +### Layout + +Clone every repository as a sibling, with the orchestration repository alongside them. + +```text +repos/ + my-api/ + my-web/ + my-worker/ + my-core/ <- the orchestration repository +``` + +### A multi-root workspace with shared tasks + +The workspace file lists every repository, including the orchestration repository itself, and defines the tasks that run them. + +```json +{ + "folders": [ + { "name": "my-api", "path": "../my-api" }, + { "name": "my-web", "path": "../my-web" }, + { "name": "my-worker", "path": "../my-worker" }, + { "name": "my-core", "path": "." } + ], + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "Local: my-api", + "detail": "Start my-api on the host with its dependency containers", + "type": "shell", + "command": "trap true INT; npm run local; exec bash", + "options": { "cwd": "${workspaceFolder:my-api}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { + "group": "local", + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Local: my-web", + "detail": "Start my-web on the host with its dependency containers", + "type": "shell", + "command": "trap true INT; npm run local; exec bash", + "options": { "cwd": "${workspaceFolder:my-web}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { + "group": "local", + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Local: Start all", + "detail": "Start every service on the host in parallel", + "dependsOn": ["Local: my-api", "Local: my-web"], + "dependsOrder": "parallel", + "problemMatcher": [] + } + ] + } +} +``` + +A developer opens the workspace, runs **Tasks: Run Task** and picks **Local: Start all**. Every service starts in its own terminal, each bringing up its own dependency containers. + +### Keep the terminal alive when you stop a service + +This detail matters more than it looks. + +```json +"command": "trap true INT; npm run local; exec bash" +``` + +By default, pressing Ctrl+C in a task terminal kills the shell, VS Code closes the terminal, and you lose the scrollback along with the working directory you had. In a workspace running four services that is a constant irritation. + +The command above fixes it in two parts: + +- `trap true INT` makes the task shell ignore the interrupt. The interrupt still reaches the application, which stops as expected, but the shell itself survives. +- `exec bash` replaces the shell once the command finishes, so the terminal stays open in the right directory, with its history intact, ready for you to run the service again or run a test. + +The result is that Ctrl+C stops the service and leaves you a usable terminal, which is what everyone expects it to do. + +Add plain terminal tasks alongside the service tasks so developers can open a shell in each repository without starting anything. + +```json +{ + "label": "Terminal: my-api", + "type": "shell", + "command": "exec bash", + "options": { "cwd": "${workspaceFolder:my-api}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { "group": "terminals", "panel": "dedicated" } +} +``` + +### Orchestration scripts + +Keep a small set of scripts in the orchestration repository, all named for what they do. + +| Script | Purpose | +| --- | --- | +| `clone` | Clone every service repository as a sibling, skipping any already present | +| `build` | Build the application image for every service | +| `start` | Start the stack, with flags for Docker mode, seeding and test suites | +| `stop` | Stop what `start` started, passing extra arguments through to compose | +| `seed` | Load test data, on the host or through a running container | +| `pull` | Pull the current branch in every repository | +| `update` | Switch every repository to the main branch and pull | +| `open` | Open every repository in the editor | +| `version` | Report the latest release tag for every service | +| `help` | List the available commands | + +Every script resolves its own location first, so it works from any directory. + +```bash +#!/bin/bash +set -e +projectRoot="$(a="/$0"; a=${a%/*}; a=${a:-.}; a=${a#/}/; cd "$a/.." || return; pwd)" +cd "${projectRoot}" +``` + +Cloning is idempotent, so running it again after a new service is added is safe. + +```bash +test -d my-api || git clone https://github.com/DEFRA/my-api.git +test -d my-web || git clone https://github.com/DEFRA/my-web.git +``` + +The `start` script parses flags rather than taking positional arguments, so options can be combined. + +```bash +seed_database=false +use_docker=false + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --docker) use_docker=true ;; + -s|--seed) seed_database=true ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + shift +done +``` + +Keep the list of services in one place near the top of each script, or in a shared file the scripts read. Repeating the service names in ten scripts means every new service is a ten-file change. + +### Compose profiles do the work + +The profile from the containerised dependencies pattern is what lets one set of scripts drive both modes. + +```bash +docker compose up -d # dependencies only, host-native development +docker compose --profile app up -d # the whole stack in containers +``` + +Each service's own `npm run local` starts only its dependencies, so a developer can run one service without the orchestration repository at all. The orchestration repository adds convenience, not a requirement. + +### Waiting for something to be ready + +Compose health checks handle container readiness. They do not tell you that an application has finished starting, which is what a seed script or a journey test actually needs. + +For a port, poll it. + +```bash +#!/bin/sh +# wait-for.sh host:port [-t timeout] [-- command args] +set -eu + +target="${1:?host:port required}" +shift + +host="${target%:*}" +port="${target##*:}" +timeout=30 + +if [ "${1:-}" = "-t" ]; then + timeout="${2:?timeout value required}" + shift 2 +fi + +if [ "${1:-}" = "--" ]; then + shift +fi + +i=0 +while [ "$i" -lt "$timeout" ]; do + if nc -z "$host" "$port" > /dev/null 2>&1; then + [ "$#" -gt 0 ] && exec "$@" + exit 0 + fi + i=$((i + 1)) + sleep 1 +done +echo "Timed out waiting for $host:$port" >&2 +exit 1 +``` + +For an application that is up but not yet useful, such as one warming a cache, poll the behaviour instead. + +```bash +for i in $(seq 1 15); do + if curl -sf "http://localhost:3001/health/ready" > /dev/null; then + break + fi + sleep 1 +done +``` + +Poll for the condition you actually depend on. A port being open rarely means what you want it to mean. diff --git a/docs/standards/local_development_standards.md b/docs/standards/local_development_standards.md new file mode 100644 index 0000000..2fcf374 --- /dev/null +++ b/docs/standards/local_development_standards.md @@ -0,0 +1,101 @@ +# Local development standards + +Every service must be straightforward to run and test on a developer machine. These standards set out what "straightforward" means. They do not say which approach to take, because the right approach depends on the service. The [local development patterns guide](../guides/local_development_patterns.md) explains the options and our preferred defaults. + +## Rationale + +- **Developer mobility and agility.** Developers move between teams, services and devices. A setup that only works on one person's machine, one operating system or one team's laptop image slows everyone down. +- **Onboarding.** The time between a new developer joining and making a useful change is a direct cost. Most of that time is spent getting things running. +- **Device constraints are real.** Defra devices are restricted by default and developers need an exception profile and elevated rights to install software. Suppliers use their own devices. Every manual install we require is friction, multiplied by everyone who joins. +- **Debugging matters more than setup.** Setup happens once. The inner loop happens multiple times a day. +- **Confidence in tests.** If tests can only be run in a pipeline, they get run less, and failures are diagnosed from logs instead of a debugger. +- **Reducing "works on my machine".** Differences between developer machines cost time that is hard to attribute and easy to repeat. + +## Terminology + +`Inner loop` - the cycle a developer repeats many times a day: change code, run it, see the result +`Dependency` - infrastructure a service needs to run, such as a database, cache, message broker or another service +`Host-native` - running the application or its tests directly on the developer machine rather than inside a container +`Orchestration repository` - a non-deployable repository that holds the tooling to run several related services together + +## Standards + +### Every service can be run locally + +Anyone with access to the repository can run the service on their own machine. Nothing needed to run it depends on undocumented knowledge, a specific person, or a manually configured machine. + +Where a dependency genuinely cannot be run locally, the repository documents what it is, why, and what to do instead. + +### Local development is not tied to a specific device or operating system + +Teams must not constrain their local development setup to a specific device or operating system. Deployed services run on Linux, per the [container standards](container_standards.md), and local development should be consistent with that: on Windows, use Windows Subsystem for Linux rather than the native Windows filesystem and tooling; on macOS, use the native environment directly. Both give a Linux-like environment that matches how the service is built and deployed. + +Only work directly on the Windows filesystem when it is unavoidable, for example a technology that does not work through WSL such as .NET Framework. + +### Dependencies are provisioned, not installed by hand + +Wherever it is possible to do so, databases, caches, message brokers and similar dependencies are provisioned automatically, normally as containers, rather than installed and configured by hand on each developer machine. + +Some dependencies cannot be run this way, and instead need a local install or a dedicated cloud instance. That is not the standard we are aiming for, it is an accepted exception where automatic provisioning is genuinely unavoidable. + +The only things a developer should have to install to meet the standard are the language runtime, a container runtime, an editor and standard command line tooling. + +Manual installation is high friction, drifts between machines, and is a common cause of support demand. + +### Local development does not depend on cloud resources + +A service can be run and developed without a connection to a deployed environment, a shared database or a cloud account. + +Where a service integrates with a cloud service, local development uses an emulator or a stub. Some integrations have no emulator and no practical stub, and a real cloud instance is unavoidable. That is an accepted exception rather than the standard. + +Shared environments are not used as a substitute for local development except where this exception applies. They cannot be reset, they cannot be worked on by two people at once, and they make every developer's work depend on everyone else's. + +### A service is simple to start, ideally with a single documented command + +Starting a service, including whatever dependencies it needs, should require as few commands as possible. Ideally one command is named consistently across the team's repositories and is stated in the README. + +If several services are normally run together, there is a documented way to start them all at once. That must not become the only way to run any individual service. + +### Tests can be run locally in full + +The whole test suite can be run on a developer machine before pushing. + +Where the suite has tiers, such as unit, integration and journey tests, each tier can also be run on its own, and the README says how. + +Tests must not depend on a developer having started something first, unless the README says so plainly. Tests that bring up their own dependencies are preferred. + +### A debugger can be attached to the running service and to a failing test + +Whatever approach a team takes, a developer must be able to set a breakpoint in the running service and in a failing test, and step through the code. + +The configuration needed to do this is committed to the repository. It is not something each developer works out for themselves. + +### A basic local run does not require real credentials + +A developer can start the service and exercise its main paths without live credentials, production secrets or access to a protected system. + +Some integrations, such as a third-party identity provider, genuinely cannot be exercised without real credentials. That is an accepted exception rather than the standard: it is kept to the specific flow that needs it, it is documented, and the rest of the service still runs without it. See [managing application credentials](../guides/application_credentials.md). + +Secrets are never committed. Repositories provide an example configuration file with safe defaults. + +### The local development approach is a deliberate, recorded choice + +The team knows how the application runs locally, how the tests run, and why. That choice is written down in the README rather than inherited by accident from a template. + +Two things are recorded: where the application runs and where its dependencies come from, and the same for the tests. They are separate decisions and are recorded separately. + +### Setup is reproducible from a clean clone + +Following the README from a fresh clone results in a working service. Setup steps are repeatable and safe to run again. + +Any setup that cannot be scripted is listed explicitly in the prerequisites, and the list is kept short. + +### The README explains how to run and test the service + +Every repository documents how to run the service in development and how to run its tests, as required by the [README standards](readme_standards.md). + +This is the check that makes all the standards above verifiable. If it is not written down, it does not count. + +## Status + +This standard was formally adopted August 2026. diff --git a/mkdocs.yml b/mkdocs.yml index bb200d1..3a5dca1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ nav: - C# coding standards: standards/csharp_coding_standards.md - Development languages: standards/development_language_standards.md - JavaScript standards: standards/javascript_standards.md + - Local development standards: standards/local_development_standards.md - Logging standards: standards/logging_standards.md - Mobile application standards: standards/mobile_app_standards.md - .NET standards: standards/net_standards.md @@ -61,12 +62,13 @@ nav: - Developer workflows: guides/developer_workflows.md - Cookie banner: guides/cookies-banner.md - Defra Identity: guides/defra-id.md - - Docker guidance: guides/docker_guidance.md + - Docker: guides/docker_guidance.md - Entra: guides/entra.md - GitHub Advanced Security: guides/github_advanced_security.md - GitHub Copilot: guides/github_copilot.md - Java auto-format with Eclipse: guides/java_auto_format_eclipse.md - Kubernetes: guides/kubernetes.md + - Local development patterns: guides/local_development_patterns.md - Managing application credentials: guides/application_credentials.md - Mobile application guidance: guides/mobile_app_guidance.md - New starters: guides/new_starters.md