Victor is a full stack software engineer who loves travelling and building things. Most recently created Ewolo, a cross-platform workout logger.
Building a secure Node.js development environment via containers

A very important aspect of security lies in reducing the attack surface something that you'd like to keep safe. While I love Node.js, the ecosystem has given rise to thousands of modules each of which increases the chances of malicious code sneaking in. Examples of such code include (but are not limited to) stealing your private SSH keys, browser password files, environment variables and much more. In this article, we will look a how we can build a secure Node.js development environment via docker containers.

Let us a pick a very basic web application that requires a database and see how we can dockerize it. Let us first start with the docker-compose.yml which resides in our project root and contains the top-level configuration for our containers:


version: "3.3"
services:
  db:
    image: mysql:5.7
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: "dockerize-it-sample"
      # So you don't have to use root, but you can if you like
      MYSQL_USER: "dockerize-it-sample"
      # You can use whatever password you like
      MYSQL_PASSWORD: "dockerize-it-sample"
      # Password for root access
      MYSQL_ROOT_PASSWORD: "dockerize-it-sample"
    ports:
      - "3306:3306"
    expose:
      - "3306"
    networks:
      - dockerize-it-sample-network
    volumes:
      - dockerize-it-sample-db:/var/lib/mysql
      
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: dockerize-it-sample
    container_name: dockerize-it-sample
    restart: unless-stopped
    environment:
      - DB_HOST=db
      - DB_PORT=3306
    ports:
      - "8080:8080"
    networks:
      - dockerize-it-sample-network
    volumes:
      - .:/home/node/app
      - node_modules:/home/node/app/node_modules
      - /home/node/app/.git
    depends_on:
      - db
    command: npm run start

networks:
  dockerize-it-sample-network:
    driver: bridge

volumes:
  dockerize-it-sample-db:
  node_modules:

So we're using docker-compose (1.25.0) with docker (19.03.12) to spin up 2 containers, one of them being a database with the second one being our application container.

Api container

The app container depends on the database and essentially copies all the contents of the project directory into the container but hides the dreaded node_modules folder. Moreover, we also hide the version control folder, .git. We pass the hostname for the database via an environment variable and setup both containers on the same network. Assuming that this is some sort of a web application running on port 8080, we expose it so that we can access the running application via the localhost. Finally, we tell the container to execute npm run start when it starts. Let us now look at the Dockerfile which defines how the api container is created:


FROM node:12-alpine

RUN apk add --no-cache git

RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

WORKDIR /home/node/app

COPY package*.json ./

USER node

RUN npm install

COPY --chown=node:node . .

Creating the api container is fairly straightforward, we use a standard Node.js docker image and then install git in case we need to install from a non-npm repository. We thereafter create a local app folder under the node user and copy the package.json and the package-lock.json files into it. We then install the dependencies locally within the container. Finally we make sure that the local user owns all the files. As an added level of precaution we can also ignore certain items from getting copied into the container via a .dockerignore file:


node_modules
npm-debug.log

Dockerfile*
docker-compose*
.dockerignore

.git*

data-*/
.vscode/

Predictably, this file functions as a .gitignore file and we skip copying over node_modules (just in case we might have have installed something locally as well). Note that since we will be mounting the current directory into the app container anything that is not specifically excluded by creating an overlay mount point will be exposed to the application!

Database container

Coming back the docker-compose.yml file, the database container uses a standard MySQL image and the only special thing about it is that we expose the MySQL port (3306) so that we can connect to the database from our local machine. We also set up a named volume (dockerize-it-sample-db) so that our data is persisted across container restarts.

Running the containers

Executing docker-compose build should build all containers and we can always run docker-compose build --no-cache to force building all the containers from scratch. Note that this can lead to a lot of intermediate containers lying about and these can likely be cleaned via a docker system prune command. Running docker-compose up will fire up both containers and we should be able to make code changes locally.

Why do this?

So now that we have dockerized everything, how does it actually help? The idea behind this exercise was that the application dependencies should be installed in a barebones container that does not provide any access to the local development environment. Moreover, the actual code is on the local machine and one can use their preferred method of development with only the execution of the code being outsourced to the container. A normal workflow such as hot-reloading code, etc. should all work out of the box. If you'd like to run tests that require an execution environment you can jump into the container itself and run commands directly: docker exec -it dockerize-it-sample /bin/sh.

Gotchas

Note that I've had some issues with typescript development where I've needed to install type definitions locally. This can be achieved via: npm i --no-package-lock --no-save @types/supertest @types/request @types/mocha @types/chai --ignore-scripts. Note that the lockfile needs to be ignored as npm installs all dependencies if it sees one. Also, we ignore all scripts for security which was the point of this exercise.

Another gotcha is the installation of dependencies from private git repositories. These depend on your private SSH key and there is unfortunately no easy way to pass sensitive information to non-swarm docker containers (i.e. single node local installation). The best that I could come up with was to create a new private key and add it as a deploy key to the private repository. This key is then copied over to the container while building and deleted after the the modules have been installed. Sample Dockerfile:


FROM node:12-alpine

RUN apk add --no-cache git openssh-client

RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

WORKDIR /home/node/app

COPY package*.json ./
COPY --chown=node:node ./docker/private-repo.key ./private-repo.key

USER node

RUN mkdir ~/.ssh 

# only for swarm
# RUN ln -s /run/secrets/host_ssh_key ~/.ssh/id_rsa
# RUN cat ~/.ssh/id_rsa

RUN ssh-keyscan -t rsa private-repo.url >> ~/.ssh/known_hosts
RUN cp ./private-repo.key ~/.ssh/id_ed25519

RUN npm install

COPY --chown=node:node . .

RUN rm ~/.ssh/id_ed25519
RUN rm ./private-repo.key

As can be noted above, for docker swarm installations one can use the secrets feature which mounts secrets in a special volume. While this is not secure as the post install scripts might be able to slurp the contents of ~/.ssh, we do limit the damage by providing a read-only key.

A practical example can be found here: https://github.com/smalldatatech/dockerize-it-sample. This is a web application that runs on 8080 and returns a directory listing of the running user's home directory. You can try it out and as always, I am very happy for your feedback.