We always had our local development environments setup with Docker on top of Vagrant. While this has worked out just fine, the overhead of building and maintaining them started to increase significantly.
Then here comes containerization with Docker gaining popularity. So, after a few months of random discussions with colleague Bez (who is by the way a Senior Software Engineer AND a really sly Magician) and with the wide availability of Docker for Mac (they just released it sometime on the last week of July last year), I decided to take it up a notch and see how well it would work.
Before we start
Here are a couple of requirements we need installed on our host machine:
File and Directory Structure
Take a look at this overview:
/some-random-project
+-- docker-compose.yml
+-- docker-sync.yml
+-- some-random-app-folder
+-- bin
| +-- start.sh
+-- Dockerfile
+-- package.json
+-- index.js
As you notice, the Dockerfile
is under some random app folder. This was intended to, as creating an additional "service" or app within the project can be containerized. This is done so by simply adding in the app folder within the project and with their own Dockerfile.
What is that start.sh
file? We'll get to that later.
The Dockerfile
FROM node:latest
ENV PROJECT_ROOT /opt/app
RUN apt-get update \
&& apt-get install -y man postgresql-client-9.4 \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pm2
RUN mkdir -p /tmp/app
COPY package.json /tmp/app/package.json
RUN cd /tmp/app/ && npm install --silent
WORKDIR $PROJECT_ROOT
COPY bin /opt/bin
CMD ["/opt/bin/start.sh"]
Since this is for a Node.js project, an official image of Node from Docker Hub should do it quite nicely. Hence, the FROM node:latest
line.
We then setup an environment variable for our project's root within the container, that would be ENV PROJECT_ROOT /opt/app
.
Then we'll install some dependencies that we need for the project. In this case, we just want a postgresql-client
and a global install for the npm
package pm2
to run our Node.js project.
RUN apt-get update \
&& apt-get install -y man postgresql-client-9.4 \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pm2
We'll also cleanup our package lists as they take up some space and we don't need any more to bloat up our soon-to-be-built Docker image.
Also did you notice how we string the commands into a single RUN
statement instead of declaring one RUN
statement for each? Since Docker creates a new layer to the image for every statement, doing this will minimize the amount of layers the image will have and thus speed things up and reduce image size.
The next 3 commands are used as a way for dependency files to be started always on a blank slate on build, forcing Docker to not use the cache when we change our application's dependencies:
RUN mkdir -p /tmp/app
COPY package.json /tmp/app/package.json
RUN cd /tmp/app/ && npm install --silent
Then set our work directory for the start.sh
default command that we'll be tackling soon enough:
WORKDIR $PROJECT_ROOT
Next we copy over our bin
folder to the container and set it as our default for the container when it executes.
COPY bin /opt/bin
CMD ["/opt/bin/start.sh"]
The docker-compose.yml configuration
Feeling accomplished after setting up our base image, we then proceed to configuring our links to supporting containers and other services we might have in mind.
For this we'll use docker-compose
which is included in the Docker for Mac app.
version: '2'
services:
postgres:
image: postgres
environment:
- POSTGRES_PASSWORD=1234
- POSTGRES_USER=postgres
- POSTGRES_DB=some-random-db
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
some-random-app:
build: ./some-random-app-folder
ports:
- '3000:3000'
volumes:
- some-random-app-sync:/opt/app:rw
links:
- postgres
volumes:
pgdata: {}
some-random-app-sync:
external: true
Breaking it down, the postgres
service configuration is pretty much self-explanatory. On the other hand, there is one neat thing to take note from the main some-random-app
service: the volumes
section. It refers to an external volume that will be provided by docker-sync.
Setting up the docker-sync
So why not use Docker's native syntax for mounting a directory from the local host as a data volume? Though it is quite useful and direct without the need for 3rd party modules, it is just REALLY SLOW and SUPER CPU INTENSIVE! Probaly on a very small scale project it could work out fine, but for bigger projects that will handle hundreds of files, it just doesn't cut it.
Thus, we define another configuration file responsible for providing the some-random-app-sync
volume, the docker-sync.yml
:
version: '2'
syncs:
some-random-app-sync:
src: './some-random-app-folder/'
dest: '/opt/app'
sync_host_ip: 'localhost'
sync_host_port: 10871
sync_group: root
sync_userid: 0
sync_strategy: 'rsync'
Some notes to keep in mind:
- The sync name must be globally unique just like how container ports are and should NOT match your real application container name.
- The
sync_host_port
should be a unique port.
The start.sh shell script
What is this start.sh
shell script for? Take a look at it's content:
#!/usr/bin/env bash
if [ ! -d $PROJECT_ROOT/node_modules ]; then
cp -a /tmp/app/node_modules $PROJECT_ROOT
fi
pm2-dev start "$PROJECT_ROOT/index.js"
First, it checks to see if the node_modules
dependencies folder exists within the project's root. If not, it copies over the dependencies from the /tmp/app
folder within the container (remember those 3 commands in the Dockerfile) to the project's root. This helps us keep the dependencies up-to-date and out of our local host, only existing within the container.
Second, we invoked the pm2-dev
command to start the app's index.js
file to run in development mode.
Don't forget to set the permissions for start.sh
to 0755
.
Sample index.js and package.json
Here is a sample index.js
file I picked up from Express.
var express = require('express')
var app = express()
app.get('/', function(req, res) {
res.send('Hello World!')
})
app.listen(3000, function() {
console.log('Listening on port: 3000...')
})
And here's the package.json
that accompanies it.
{
"dependencies": {
"express": "^4.14.0"
}
}
Firing it up!
Now to get the show running, run the command docker-sync start
which creates and starts the sync containers, watchers and the sync itself. The command will start a long-running process that runs in the shell's foreground. Pressing Ctrl-C
will stop the containers (but it will not remove them). If you wish to run them in the background, you can press Ctrl-Z
, followed by executing the bg
command. You can bring it back to the foreground by executing the fg
command anytime within the same shell session.
Then we'd have to open another tab on the terminal and run docker-compose up
to finally get the Docker images built and the application containers initialized.
Don't we have anything that can just run them both in one command? YES we have! Thankfully, docker-sync has a tool called docker-sync-stack
. Using this, you can start the sync service and docker compose with one single command.
So to start our project, just run:
$ docker-sync-stack start
This is very convenient so you only need one shell and one command to start working and then CTRL-C
to stop.
You can then access your project in the browser and should see a Hello World
text from http://localhost:3000/
.
When you are finished, you could actually clean up with:
$ docker-sync-stack clean
This cleans the sync-service like docker-sync clean
and also removes the application stack like docker-compose down
.
There you have it, we now have a very functional Docker local development environment for Node.js on MacOS without the need for Vagrant. Isn't it amazing?