Recently revisited development environment of some projects in favor of Docker Compose.

My main requirement for a development environment is to require the least number of packages to be installed on my system. That’s why I run everything in Docker containers: Docker is not installed by default on any system, it is not the best thing at achieving isolation, not the best at limiting footprint, but is perfect for getting a quick environment that just works. I use this container for both running the services, but also manipulating them with language specific commands: when I want to install an NPM dependency I start a shell in the application container and run npm install xxx.

This used to work well, I’ve got used to have “long” commands in readme which I can quickly copy and paste in my shell. But I still struggled with how to get local files updated in the container, is used to be mounting the entire project into the container and having something like nodemon to watch changes. This was brittle, file ownership required user ids to be identical between host and container, build artifacts appeared in the host folder, dependencies and installs were getting desynchronized, …

To get rid of long commands and get cleaner file mounts, I decided to use Docker Compose. I have a main service that runs the software. Thanks to Docker Compose watch I can get image rebuilt on structural change (added dependency) and cleaner file synchronization on source change. I also declare another service named shell that mounts the host folder (hence not solving user id requirement). It is based on the same Dockerfile but relies on multi-stage build to stop at an earlier stage than running service. This service is not enabled by default thanks to having a Docker Compose profile.

services:
  sport:
    image: gitea.mais-h.eu/mathieu/xxx
    build:
      context: .
      target: builder
      args:
        NODE_ENV: development
    develop:
      watch:
      - path: Dockerfile
        action: rebuild
      - path: package.json
        action: rebuild
      - path: package-lock.json
        action: rebuild
      - path: esbuild.js
        target: /app/esbuild.js
        action: sync
      - path: src
        target: /app/src
        action: sync
    command:
    - npm
    - run
    - start-dev
    environment:
      PORT: "8090"
    ports:
      - "8090:8090"
    restart: on-failure

  shell:
    profiles:
    - shell
    image: gitea.mais-h.eu/mathieu/xxx-shell
    build:
      context: .
      target: dev
    volumes:
    - ./:/host
    working_dir: /host
    command: /bin/bash
    restart: no

For a Node project, I prefix command by a synchronization task which copies node_modules to the host, allowing my IDE to pick up types:

  shell:
    command:
    - sh
    - -c
    - "echo 'copying node modules...' && rm -rf /host/node_modules && cp -r /app/node_modules /host/node_modules && echo 'node modules copied' && exec sh"

The new commands are quite shorter than they used to, and there are no longer any project-specific arguments which makes them easier to remember. Running the application, auto updated:

docker compose watch

Running the shell:

docker compose run --rm --build shell

From the shell I can run project commands like npm run lint or bundle add xxx. Sometimes test require access to build files, and I run them from the main service rather than the shell:

docker compose run --rm --build xxx npm run test

My Dockerfile are usually structured in four stages:

  1. base: the very basic, starting from a base image and configuring user and directories
  2. dev: the development environment, starting from base image and installing development dependencies (that’s the one targeted by shell)
  3. builder: builds/compile the project, starting from dev
  4. runner: the running environment, usually restarting from base but it can be an other image (for example this blog is a static website built with a Ruby base image but using Nginx for running, in that case dev and base stages can be merged) and copying files from builder

NODE_ENV argument allows choosing either raw or optimized/minified build.

ARG NODE_ENV=production

FROM node:xxx AS base

RUN mkdir /app
WORKDIR /app
RUN chown node:node -R /app
USER node

# -----------------------------------------------------------------------------

FROM base AS dev

ENV NODE_ENV=development
COPY package.json package-lock.json ./
RUN npm ci

# -----------------------------------------------------------------------------

FROM dev AS builder

ENV NODE_ENV=$NODE_ENV
COPY . /app
RUN npm run build

# -----------------------------------------------------------------------------

FROM base AS runner

ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci

COPY --from=builder /app/dist ./dist

CMD ["npm", "run", "server"]