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.