Starting projects in the container

by Erik Altman on 2019-10-30

Node version and package management can be challenging, especially when you are working on multiple projects or any project over a longer period of time. Tools like nvm for managing multiple node versions on your host become essential to your workflow. It takes dilligence to maintain a single node environment on your development host so that it matches your build environment, let alone several node environments for different projects. Doing so takes time, can break easily and cannot be shared with other developers.

Web application developers commonly prepare docker images to deploy their applications but many popular guides and Dockerfile examples suggest that development and sometimes production build processes are completed on the development host (I see this a lot in the most simple Angular examples). This is great for getting new projects started but can lead to problems later as the host environment and the project grow apart or when additional developers or build agents enter the scene.

I also know from experience that it is a common practice to create a new project and then dockerize it later as needed. This process can be wasteful as compared to initially designing the project for a specific environment. If you are developing directly on your host, you might not take the time to make sure that you are on the most appropriate version of node for your project and install already outdated dependencies or you might use globally installed packages and leave them unaccounted for. You would then have to sort these issues out later, which might cause rework or mysterious compilation issues.

Lucky for us, we can "dockerize" our project from the get-go, including our development environment, and benefit from supreme stability and portability without ever having to manage our development host's node environment again. This post will explain how to start a brand new node project in a container and how to develop in the container with vscode.

This guide assumes that you have the docker tools installed and some familiarity with them, but haven't spent much time configuring them yourself. It also assumes that you have some familiarity developing front end web applications with node. The example code will assume you are developing an Angular application but the guide itself will try to be platform agnostic.

Here is a gist with the example code that is outlined in this post.

Starting a new project

You can start your project off with perfect control of your node environment by creating a Dockerfile, docker-compose.yml file, a .dockerignore file and issuing a single docker-compose run command.

Create your Dockerfile

Your Dockerfile will define your development environment. As your project matures you can add additional Dockerfiles for preparing your test and production builds. Let's assume you want to use node to develop a front end web application. Your Dockerfile should start with a FROM instruction referencing the node version you want to work with. For a new project I would recommend using the latest stable version. To determine what version that is, check out nodejs.org and then pick the image you want from Node on Docker Hub. At the time of writing this, the latest stable node version is 12.13.0 and alpine linux is very slim and should be well suited for web application development, so I would recommend starting with that.

FROM node:12.13.0-alpine

Next let's setup our linux environment. Let's use the Alpine Linux package manager to update our environment and then remove all the files that are created in the process. Let's garbage collect our node installation and rename our alpine shell to sh for any other scripts we want to execute. I also recommend adding git to this environment so that you can take complete advantage of the git related features in vscode remote development. Add the following to your Dockerfile to accomplish these steps.

#Linux setup
RUN apk update \
  && apk add --update alpine-sdk \
  && apk add git --no-cache \
  && apk del alpine-sdk \
  && rm -rf /tmp/* /var/cache/apk/* *.tar.gz ~/.npm \
  && npm cache verify \
  && sed -i -e "s/bin\/ash/bin\/sh/" /etc/passwd

Now that our Linux environment will be setup for us, let's create a place for our project in the container running this image. Let's follow some Linux conventions and put our project in /usr/src/app. Add the following to your Dockerfile to create an environment variable called HOME that will be the path to our project in the container and set it to the working directory.

ENV HOME=/usr/src/app
RUN mkdir -p $HOME
WORKDIR $HOME

That is pretty much all we need to include in our Dockerfile to get a node project started. If you are going to use npx and a package that you typically just use once to create your project like create-react-app, you could spin this image up in a container and run npx create-react-app my-app to start your project. If you are going to start your project with a package that you will use again to compile, scaffold or run other operations, then you should add those tools to this Dockerfile as well, so that they will be available any time you spin your container up. For an Angular CLI app add the following to install the cli in your image.

#Angular CLI
RUN npm install -g @angular/cli

This will install the angular cli in your development container so that you can use the it to initialize and scaffold your project.

Create your docker-compose.yml file

So great, we're well on our way to consistently and dependably deploying our development environment. We can use docker build to create an image from this file that has everything we need to start developing our project. But what good is our development if we're doing it in an ephemeral virtual environment? I recommend creating a docker-compose.yml file and defining a volume to map your project's directory on your host machine to /usr/src/app. This will allow you to run your container, initialize your project and see it populate the directory back on your host machine. The docker-compose file should look like this

version: "3"
services:
  node:
    build: .
    ports:
      - "4200:4200"
    volumes:
      - .:/usr/src/app
      - /usr/src/app/node_modules/

The ports that you map will depend on your project, in this example were mapping 4200 because the Angular CLI serves your project there for development by default. For create-react-app you might instead expose the default port 3000 instead.

The volumes section of the docker-compose file is particularly important. Here we define directories that should be mounted from the file system onto the container once our image is up and running. In the first line in volumes we provide instructions to mount our project directory (.) to our working directory in the container (/usr/src/app). This way changes that we make to our project are immediately applied to the file system on our development host and within our container where they should trigger our development environment's watch process to recompile our app. In the second line in volumes we are defining a volume with a target only. This will prevent our development host's node_modules folder from being mounted in the container and allow us to use the modules as installed in our development container instead.

Bring up your environment

Now that you have your docker image and container configurations for your development environment defined, you can spin it up with docker-compose. Run the following command to create build from your Dockerfile and run it in a new container.

docker-compose run node sh

When this command completes, you will be returned to a shell session inside of your container. You can use this shell session to initialize your project.

Create your project

Now that you have your development environment up and running, you can initialize your node project. If you are developing an angular application, you would go ahead and run a command like the following to create your new app (where my-new-app is the name for your project).

ng new my-new-app --directory ./

If you are developing a react application you might instead use the following commands to create your new app in a new directory

npx create-react-app my-new-app
mv my-new-app/* .
rm -rf my-new-app
npm i

Creating the react app in a new directory and then moving the files is recommended because create-react-app will notice that our project has an empty node_modules folder and it will not overwrite it. This empty node_modules folder exists because of the way that docker supports in ignoring that directory in our volume, and this is a simple workaround.

As you create your application you should notice that the files being created in your container are appearing in your local directory for your project. This is because the volumes declared in your docker-compose.yml file tell docker to mount your local project directory to /usr/src/app in your container and this is where the files are being created!

Extend your Dockerfile to serve your development process

Now that you have created your project you can extend your Dockerfile to install your app's dependencies and start running your development process.

We can use the COPY instruction to copy our project into the image and the install our project's dependencies as configured in our package.json/package-lock.json files. Add the following to your Dockerfile to copy your project over and install its dependencies.

COPY . .
RUN npm install

This COPY instruction is going to copy all of our files, but we don't actually want all of our files to be copied over. We could create a more specific COPY instruction naming only the files that we want copied over although there is an easier solution. We should create a .dockerignore file and list patterns for directories and files that we do not want to include in the docker build context. Important directories to exclude in a node web application would be node_modules/ and our build output directory (which is dist/ in our Angular CLI project and build/ in a create-react-app project). Some might want to include .git, but because this is our development container it can be helpful to include our projects git files. This allows us to use vscode remote development to attach vscode to our container and make use of git tools while developing in our container.

So lets go ahead and create a file in the root of our project named .dockerignore and list those directories in it.

node_modules/
dist/

Now that we have our .dockerignore file created lets finish out our Dockerfile. We need to include an instruction to expose the port for our development process. In the case of an Angular CLI project this is port 4200 and for create-react-app this is going to be port 3000.

Next we should include an instruction to start our development process. Most of the time this can be done with the npm start script declared in our package.json file. For an Angular CLI or create-react-app project, we can use npm run start.

These final additions to our Dockerfile should be:

EXPOSE 4200
CMD ["npm", "run", "start"]

Depending on your project, there might be just one more important step before we can get to development! Typically Angular CLI is going to serve your development process on your localhost (127.0.0.1) but this IP in the container will not be accessible from your development host. A simple way to access your app from your development host is to run it on 0.0.0.0 which is a wildcard IP address matching any possible incoming port.

I like to modify my start script in my package.json to include this configuration. For an Angular CLI project, I modify the start script to ng serve --host 0.0.0.0. create-react-app already serves your app on this host and so no modification should be required.

Developing in the container

Now that we have updated our Dockerfile to run our development process, lets exit the container and start it again to serve our project. Select your terminal and press ctrl+c or type exit to exit the container. Now that our Dockerfile ends in a command that will serve our app, we can build it with docker-compose build and start it up again with docker-compose up.

You should see docker spin up your development process and you should be able to proceed to http://localhost:4200 (or other specified port) and find your application being served there.

If you are using vscode remote development, you might want to attach your vscode window to the running container so that you can benefit from intellisense. This would also allow you to conveniently access the terminal in your container from your vscode window. In an upcoming post I will explore vscode remote development in greater depth.

If you are not planning on using vscode remote development, you might want to go ahead and npm install your dependencies on your host machine so that you can use intellisense and avoid seeing red while vscode complains that all of your project's dependencies are missing on your host. To access the terminal in your development container, open a new terminal window and run docker ps to list your containers and then docker-compose exec {container name} -it sh to jump to the shell in your container.

When you are done developing your app or you want to stop your container for any reason, you can issue the command docker-compose stop from your project directory or docker ps to list your containers and then docker stop {container name} to stop the container.

Wrapping Up

I hope you found this post to be a helpful introduction to Docker and enjoy doing your development in the container. Learning this approach and containerizing my development has been a huge relief to me as a node developer who is swimming in projects spanning from node versions 12 to as far back as 4. All of my work seems more worthwhile because I am certain that my projects can be reproduced apart from my development host.