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.
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
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!
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
Running the containers
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.
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
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.