Creating a development environment with docker and docker compose

Did you ever try to run code on your local machine, but it simply didn’t work? And when you ask for help all you hear back is "It works on my machine"? Well, Docker can be a good solution to end not just this, but many other problems like that.

In this article, we are going to create a development environment with Javascript language but these concepts can be used for other languages as well.
Grab your coffee (or beer), and let’s dig into it!

If you don’t have docker and docker-compose installed on your computer, I’m going to make your life easier and leave the links you need to install it bellow:

Docker install
Docker Compose install

Note: If you use Linux with WSL2 check your network, firewall, and DNS settings.


JavaScript/NodeJS Environment

First, create a folder in the project directory of your preference, then create and open a Dockerfile.dev file with the contents of the example below:

FROM node:17.8

RUN npm config set cache /home/node/app/.npm-cache --global
RUN npm install -g npm@8.6.0

USER node

RUN mkdir /home/node/poc
WORKDIR /home/node/poc
COPY . .

EXPOSE 9091

CMD ["tail", "-f", "/dev/null"]

Let’s break down the block above:

  • FROM: refers to the base image to be used in the application. Other possibilities are: alpine (use sh and not bash. This command is apk based), slim, slim-buster, stretch, buster, etc (for more informations access Images Node);
  • WORKDIR: the main path or root of the container;
  • RUN: executes shell commands, sudo isn’t required
  • COPY: specifies the folders/files to be copied into the container (dot . equals all files and folders). These files are going to be placed in the path set in WORKDIR.
  • EXPOSE: ports visible for network use;
  • CMD: commands to be executed at the end of the Dockerfile. They’ll keep the container running.

Another thing we can do when creating the Dockerfile.dev is to change the user in the container:

FROM node:17.8

RUN groupmod -g 1001 node && usermod -u 1001 -g 1001 node
RUN adduser --disabled-password -uid 1000 user-poc
RUN mkdir /home/user-poc/poc/

RUN npm install -g npm@8.6.0

USER user-poc

RUN mkdir /home/user-poc/poc
WORKDIR /home/user-poc/poc 

COPY . .

EXPOSE 9091

CMD ["tail", "-f", "/dev/null"]

What changes between the two?

  • change the user node and group node for another uid
  • add a user with another uid (consult your uid in the terminal)

After creating the file, there’s two ways to run it:

  1. docker build -t nametag:tagimage -f Dockerfile.dev .
  2. Use docker-compose

For this scenario, we’ll be using the second one.

Docker Compose

"Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services."(Docker compose overview)

If you’re working with Node.JS you might ask yourself "Do I need to install Node.js?" And the answer is no, you don’t! With docker the image set in Dockerfile already contains all configurations for your selected language.

How to create this YAML file? First, create a file docker-compose.yaml or docker-compose.yml with the contents below:

version: '3.4'

services:
 poc:
   container_name: poc-js
   build:
     context: .
     dockerfile: Dockerfile.dev
   ports:
     - "9091:9091"
   volumes:
     - ".:/home/node/poc"

Let’s take a closer look at the contents of the file:

  • version: this is the specification version. Check this link on changing versions and compatibility information. Note that specifications of versions higher than 3.4 require at least docker-compose version 2 to work.
  • services: is the relation of containers we’ll create:
  • poc: this is your service’s name. You can give it any name you want (like frontend, backend, database, etc).
    • container_name: container’s display name, especially practical when used with docker ps or docker-compose ps
    • build: configuration for image creation:
      • context: path to the Dockerfile folder;
      • dockerfile: reference to the Dockerfile;
    • ports: Ports to be exposed and bound;
    • volumes: paths that must be accessible by containers.

After finishing the configuration file, you just need to run this on your terminal window:

docker-compose up -d --build

This command will build and create the container. The -d (or –detach) option is set to run the containers in the background so it won’t block your terminal. To check the containers currently running you can do the following:

#docker container
docker ps

CONTAINER ID   IMAGE        COMMAND                  CREATED       STATUS       PORTS                              NAMES
eb76817fab00   poc-js_poc   "./.docker/entrypoin…"   4 hours ago   Up 4 hours   9000/tcp, 0.0.0.0:9091->9091/tcp   poc-js

#docker compose version 1
docker-compose ps 

#docker compose version 2
docker compose ps

 Name            Command           State                Ports              
---------------------------------------------------------------------------
poc-js   ./.docker/entrypoint.sh   Up      9000/tcp, 0.0.0.0:9091->9091/tcp

To access the container you can just run:

docker exec -it poc-js bash

#result
node@eb76817fab00:~/poc$ 

Now run the command:

npm init -y

For this app, we’ll make a single change in the package.json file. Simply add the type: module property for ECModules used in the project. It should look something like this:

{
  "name": "poc",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module"  
}

After that, we need to install some packages for dev env. We’ll begin with tests:

npm install -D jest

Create a jest.config.mjs file with the contents below:

/*
 * For a detailed explanation regarding each configuration property, visit:
 * https://jestjs.io/docs/configuration
 */

export default {
  // Automatically clear mock calls, instances and results before running any test
  clearMocks: true,
  // Indicates which provider should be used to instrument code for coverage
  coverageProvider: "v8",
  // A map from regular expressions to paths to transformers
  transform: {}
};

In package.json again, set these lines:

 "scripts": {
    "test": "NODE_OPTIONS=--experimental-vm-modules npx jest",
    "test:coverage": "NODE_OPTIONS=--experimental-vm-modules npx jest --coverage"
  },

A few more thigs we need to do:

  1. Create the folder ./test/unit (mkdir test && mkdir test/unit)
  2. Create the file Item.test.js
  3. Paste the code below in Item.test.js:

    describe('Item', function () {
    it('Get value item', function () {
    const item = new Item({
      name: 'Billabong T-SHIRT AI',
      category: 'T-SHIRT',
      description: 'T-SHIRT ANDY IRONS',
      value: 30,
      quantity: 1
    });
    expect(item.getTotalValue()).toBe(30)
    });
    
    it('Should return error: Value is required', function () {
    expect(function () {
      new Item({
        name: 'Billabong T-SHIRT AI',
        category: 'T-SHIRT',
        description: 'T-SHIRT ANDY IRONS',
        quantity: 1
      }).toThrow(new Error("Value is required"));
    });
    });
    
    it('Should return error: Quantity is required', function () {
    expect(function () {
      new Item({
        name: 'Billabong T-SHIRT AI',
        category: 'T-SHIRT',
        description: 'T-SHIRT ANDY IRONS',
        value: 30
      }).toThrow(new Error("Quantity is required"));
    });
    });
  4. Create the folder ./src (mkdir src && mkdir src/domain && mkdir src/domain/entity)
  5. Create file Item.js with these contents:

    export default class Item {
    #name
    #category
    #description
    #value
    #quantity
    
    constructor({ name, category, description, value = undefined, quantity = undefined }) {
    this.#name = name
    this.#category = category
    this.#description = description
    this.#value = value
    this.#quantity = quantity
    this.validation()
    }
    
    validation() {
    if (this.#value === undefined) throw new Error("Value is required")
    if (this.#quantity === undefined) throw new Error("Quantity is required")
    }
    
    getTotalValue() {
    return this.#quantity * this.#value
    }
    }
  6. Import Item.js in Item.test.js:
    import Item from "../../src/domain/entity/Item.js"

Now try running npm t or npm run test and to check tests coverage run npm run test:coverage

After finishing the all the configuration needed, we might be left with the following scenario: think about setting up this app and running it for the first time. We’d have to run npm install every time and that’s not practical.

We need to configure Dockerfile so our initial container executes npm install automatically. Follow these steps:

  1. Create folder .docker;
  2. Create file entrypoint.sh with the contents below:
    
    #!/bin/bash

cd /home/user-poc/poc
npm i

tail -f /dev/null

3. Remove the CMD entry in the Dockerfile.dev file;
4. Set folder's permissions: chmod -R 777 .docker/
5. Change the docker-compose.yaml file by adding an entrypoint line to look like this:
```yaml
version: '3.4'
services:
    poc:
        container_name: poc-js
        entrypoint: ./.docker/entrypoint.sh
        build:
        context: .
        dockerfile: Dockerfile.dev
        ports:
        - "9091:9091"
        volumes:
        - ".:/home/node/poc"

After all those steps, you can build the container with the docker-compose command.

With that, the configuration is finished. Keep in mind this is one of many possible configurations for this kind of project and they may all vary according to your specific project needs.

To check out the results of all the steps listed here, access this repository

References

https://docs.docker.com/engine/reference/builder/
https://github.com/alohaguilherme/docker-developer-enviroment

We want to work with you. Check out our "What We Do" section!