Docker Compose development environment
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:
base
: the very basic, starting from a base image and configuring user and directoriesdev
: the development environment, starting frombase
image and installing development dependencies (that’s the one targeted by shell)builder
: builds/compile the project, starting fromdev
runner
: the running environment, usually restarting frombase
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 casedev
andbase
stages can be merged) and copying files frombuilder
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"]