- Published on
The art of shipping to production
- Authors
- Name
- Giovanni Orciuolo
- @Wolfrost
Ok, let's pretend we have a project that needs to be put online. The project's code lives in its nice Git repository, obviously. How do we build a production system with the goal of pushing a new version in the shortest time possible?
I usually create an orchestrator, which is a separate project (that therefore always lives in a separate repo), whose only purpose is to orchestrate, as the name says, the release to production. But how does it work?
1 - 'Dockerization' of the project
The first step depends on the nature of the project. Is it a client and a server written in React and Node respectively? Or is the client written in Angular, and the server in Ruby on Rails? Who knows.
And that's the point: we need a system that lets us "isolate" the project and its dependencies inside a "container", so we can avoid any kind of external setup directly on the OS of the host machine (for example a VPS). This brings countless organizational advantages, first of all the possibility of re-installing the project in a few minutes on a new host, plus the obvious intrinsic security advantage you get from having the project run in a sort of "sandbox" isolated from the rest of the OS.
Docker is exactly this: a container manager.
1.1 - The Dockerfile
How do you create a container? First of all you need to create one or more Dockerfiles in the various roots of the project's repos. Picture the Dockerfile as a sort of "butler" that step-by-step (line by line) runs the commands we order it to run, commands that build the project in production mode, and finally expose it to the network.
1.2 - Example with React
Here's an example for React:
# Build lane
FROM node:latest as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ADD package.json /usr/src/app/package.json
RUN npm install --silent
ADD . /usr/src/app
RUN npm run build
# Production lane
FROM nginx:1.13.5-alpine
RUN rm -rf /etc/nginx/conf.d/*
COPY nginx.conf /etc/nginx/conf.d/
COPY --from=builder /usr/src/app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
In this case the Dockerfile is split into two lanes, the first is the "build lane" that takes care of building the project, the second is the "production lane" that takes care of actually exposing online what was built in the previous lane.
The FROM directive is super important: with it we
specify an existing image as the base for this new image.
That's right: the Dockerfile doesn't actually build the container,
it only defines how to build the image, which is a sort of
"skeleton" on top of which the actual container will run.
In this case we tell the butler to use the latest version of the official Node image as the base, so we already have Node and npm pre-installed and ready in the future container, and I can run the install and build commands through the latter.
So I create the /usr/src/app directory where I'll put the project's
files, and with WORKDIR I say that all the following commands
will be run from this directory.
After adding the .bin/ folder of node_modules/ to the system PATH, I add the package.json file to the directory. This step is special because by adding package.json before running npm install I'm storing it in the cache (because every Dockerfile command is cached), which lets me avoid running npm install every single time. This speeds up image creation a lot when package.json doesn't change (for example when I ship a hotfix to production and want to be quick)
Now I can run npm install (with the silent flag to say that
I don't care about its output). Then I run the build command from npm, which
in turn will run react-scripts build.
Since I added ./bin to the PATH, now it knows where to find
react-scripts to run this command!
Once the build succeeds, a new folder called
build will have appeared, and the build phase is done.
The production phase uses Nginx 1.13.5 on Alpine as its base image (Alpine is a super lightweight Linux, so light that if you blow hard it flies away). I use Nginx to serve the HTML/JS/CSS/etc files created by the build process just because I'm comfortable with it, honestly I prefer it to Apache, but it's up to you to choose your poison.
I put my configuration file inside Nginx:
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
Without going too much into detail I'm defining a new server that
runs on port 80: at location / it takes the files in the
/usr/share/nginx/html directory and tries to show index.html
Now that we've said where to look for the files, all that's left is to
copy them from the previous lane with the COPY command. Finally
I expose port 80 (because that's where I said the server runs!)
and run the command needed to run Nginx on stdout, so
if there are errors I see them directly in the console.
At the end of the day, I'll have a container that actually runs Nginx, which shows you the final React project on port 80 of the network.
Note - Exposed ports are virtual
It's super important to understand one fundamental thing: now that I've exposed port 80, I haven't actually exposed it to the outside world, I've exposed it only inside the container. In other words, only other containers that are part of the same Docker network will be able to see it...
It will then be docker-compose's job to define the real ports that point to these virtual ports. I know, it's complicated...
1.3 - Example with Node
This is a Dockerfile for a Node-based server:
FROM keymetrics/pm2:latest-alpine
WORKDIR /home/app
COPY package.json /home/app/package.json
RUN npm install
COPY . /home/app
CMD [ "pm2-runtime", "start", "pm2.json" ]
As the base I used the latest version of PM2 on Alpine. PM2 keeps Node alive 24/7 and restarts it in case of errors. The pm2.json configuration I use is the following:
{
"name": "node-server-example",
"script": "index.js",
"instances": 2,
"exec_mode": "cluster",
"env": {
"NODE_ENV": "production"
}
}
Notice that this time there's only the production lane. That's because
this server doesn't need to be built, only served.
I don't even use the EXPOSE command because the server already
starts on its own and listens on port 5000 (because I told it
to in index.js)
Now that we have 2 Dockerfiles, we just need to make them talk to each other and create the actual containers.
2 - The conductor: docker-compose
Just like an orchestra conductor, docker-compose takes care of
building the containers and making them talk to each other through a
network. As usual we'll be the ones giving instructions to this tool,
and to do that we use a file called docker-compose.yml
I create a file to manage the two Dockerfiles I just created:
version: '3.3'
services:
server:
image: or2/node-server-test
container_name: node-server-test
hostname: node-server
restart: always
build:
context: ./node-server-test
dockerfile: Dockerfile
client:
image: or2/react-client-test
container_name: react-client-test
hostname: react-client
restart: always
build:
context: ./react-client-test
dockerfile: Dockerfile
environment:
- NODE_ENV=production
depends_on:
- server
A docker-compose.yml is made of N services, in this case just 2. In both cases I tell compose that the build process must happen in the context of the project folder, using the Dockerfile file.
The hostname directive (node-server and react-client) is important, we'll need it later.
Obviously this file, just created in the root of the orchestrator project, will refer to two project folders that will be cloned from git, so these two folders in turn must not be added to the orchestrator's git repo (in plain words, put them in .gitignore!)
Now we can run the command of a lifetime:
docker-compose up -d --build
-d is a flag to say "run the containers in detached mode", otherwise the containers run in the context of your terminal session, and as soon as you close it they shut down.
--build tells docker-compose to build the images as defined in the configuration file. If I didn't use it, it would just restart the containers without updating them.
After building the network, the images and the containers, by running the command
docker ps -a
We can see 2 containers happily running.
But it's not over yet: these containers are isolated from the outside world! Their ports are virtual, so they can see each other but the outside world can't see them.
The last piece is missing: a proxy that takes care of exactly this.
3 - The missing link: the proxy
Spoiler: it will be Nginx again, with a configuration meant for exposing what lives in the containers.
I create a new folder in the orchestrator project (which I call nginx) and inside it I throw a file called nginx.conf
worker_processes 2;
events { worker_connections 1024; }
http {
server {
listen 80;
listen [::]:80;
server_name test.or2.life;
location /api/ {
rewrite /api(.*) $1 break;
proxy_connect_timeout 1200;
proxy_send_timeout 1200;
proxy_read_timeout 1200;
send_timeout 1200;
proxy_ignore_client_abort on;
proxy_pass http://node-server:5000;
}
location / {
proxy_connect_timeout 1200;
proxy_send_timeout 1200;
proxy_read_timeout 1200;
send_timeout 1200;
proxy_ignore_client_abort on;
proxy_pass http://react-client;
}
}
}
I create a single server on port 80, which I call "test.or2.life". This domain obviously doesn't exist, but since I own the or2.life domain I could create a third-level DNS record (getting test.or2.life) that points right at the IP of the machine where all this stuff is running. But I won't, because this is just an example.
I define 2 fundamental locations: /api/ and /
On /api/, through the proxy_pass directive, I point to the hostname node-server (which it will recognize because it will be part of the same docker-compose network) on port 5000 (because that's where it runs)
In the same way, on / with proxy_pass I point to the hostname react-client, this time on port 80 (if you don't write the port and you're on HTTP, it defaults to 80)
Through this system, called a Reverse Proxy, I can expose to the outside world what runs in the containers.
So test.or2.life/api will point to the server, anything else will try to point to the client. Handy, right?
All that's left is to add this new service to docker-compose. This time I don't need any Dockerfile because I'm only using Nginx, and I don't need any weird build process.
I define the service like this:
proxy:
image: nginx:stable-alpine
container_name: proxy
restart: always
ports:
- '80:80'
volumes:
- ./nginx:/etc/nginx/
Notice the new ports directive: I say that the real port 80 points to the virtual port 80, which inside the container corresponds to the server I defined with its proxies.
Another important directive is volumes: I say that the nginx folder on my machine points to the /etc/nginx folder in the container. So the nginx.conf file will live in the container without having to use a Dockerfile to copy it. Neat!
Now if I run docker-compose again I'll get a third container called proxy, and if I go to test.or2.life I'll find the client waiting for me.
The release to production is done, go in peace!
(Maybe...)
3.1 - HTTPS with Letsencrypt
All very nice, yes, it works, but HTTPS?
Today is your lucky day: thanks to Letsencrypt it will be super easy to create an HTTPS certificate for free, and with the system we already set up we'll just need to add a couple of configurations to the proxy and we'll be ready.
You have two options: you can run Certbot (which is the Letsencrypt CLI) on Docker, or install it on the machine and call it a day.
Personally I prefer installing it on the machine in this case, also because it's not software that needs constant updates and in the worst case you'll just reinstall it on a new machine if you move to a different host.
Installing Certbot is very easy: just follow the instructions from here: https://certbot.eff.org/instructions
Stop at step 3 (the one to install it) and don't go any further, because the rest of the steps are a bit different in our case.
But first a bit of theory: our goal is to get
an HTTPS certificate from Letsencrypt. These certificates will
then be available in the /etc/letsencrypt/live folder.
A valid certificate is made of two PEM files (.pem extension), named fullchain.pem and privkey.pem
To get a certificate you need to request it from the authority and pass a "challenge" of your choice. List of available challenges
The HTTP-01 challenge is perfect for our case: to pass it you need to expose any file under the location
http://test.or2.life/.well-known/acme-challenge/<TOKEN>
For this challenge to work we obviously need to change the Nginx configuration to expose our file. But first, let's create it.
I create a folder named letsencrypt, inside it I put a folder named .well-known, and inside that one I put a file named index.html In this HTML file I can write literally whatever I want, as long as it exists and it's reachable.
Now that I have the file, let's go to nginx/nginx.conf and add a new location:
location ^~ /.well-known {
allow all;
root /data/letsencrypt;
}
The new location will catch /.well-known and any of its sublocations, and will point to /data/letsencrypt in the container.
Let's change the proxy service to add a new volume:
proxy:
image: nginx:stable-alpine
container_name: proxy
restart: always
ports:
- '80:80'
volumes:
- ./nginx:/etc/nginx/
- ./letsencrypt:/data/letsencrypt/
Everything in the letsencrypt folder will be in the /data/letsencrypt folder of the container, which is exactly the one the new location we added points to!
I run docker-compose up -d --build again to restart
the proxy, and I try visiting the site under /.well-known/test to
check whether I can see the HTML file I created before.
If you can't see it, there's definitely something wrong in the Nginx config.
As soon as you can see it, we're sure to pass the challenge, so all that's left is to request the certificate:
sudo certbot certonly --webroot -w letsencrypt/ -d test.or2.life
With this command I ask certbot to only generate a certificate (certonly) with the HTTP-01 challenge (--webroot), specifying the letsencrypt/ folder as the source (-w letsencrypt/) for the domain test.or2.life
It will try to access the file under well-known, and if it succeeds it will give you the certificate! Simple, right?
Now the certificate will be available under
/etc/letsencrypt/live/test.or2.life
This certificate is valid for 90 days, but we're lucky: Certbot creates a CRON job that requests a new certificate every 30 days, so we don't have to worry! And we can rest easy knowing the challenge will always work, because the file is always available for reading.
Now all that's left is to change nginx/nginx.conf to serve HTTPS, add a volume that points to the certificates, and we're done!
Here's the new configuration:
worker_processes 2;
events { worker_connections 1024; }
http {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name test.or2.life;
ssl_certificate /etc/letsencrypt/live/test.or2.life/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/test.or2.life/privkey.pem;
location /api/ {
rewrite /api(.*) $1 break;
proxy_connect_timeout 1200;
proxy_send_timeout 1200;
proxy_read_timeout 1200;
send_timeout 1200;
proxy_ignore_client_abort on;
proxy_pass http://node-server:5000;
}
location / {
proxy_connect_timeout 1200;
proxy_send_timeout 1200;
proxy_read_timeout 1200;
send_timeout 1200;
proxy_ignore_client_abort on;
proxy_pass http://react-client;
}
}
server {
listen 80;
listen [::]:80;
server_name test.or2.life;
location ^~ /.well-known {
allow all;
root /data/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
}
Now I have 2 servers: one runs on port 80 (HTTP default) and one on port 443 (HTTPS default). The server on port 80 only acts as a redirect to HTTPS (if you visit the site over HTTP, you'll be immediately redirected to HTTPS), and serves the file for the challenge that's needed when the certificate has to be renewed.
On the server on port 443, I say to use the result of the Certbot call as the SSL certificate and private key.
Done! Now let's update docker-compose.yml accordingly.
proxy:
image: nginx:stable-alpine
container_name: proxy
restart: always
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx:/etc/nginx/
- ./letsencrypt:/data/letsencrypt/
- /etc/letsencrypt:/etc/letsencrypt/
I added that the real port 443 points to the virtual port 443 (which now exists and points to our HTTPS server), and I added a volume that points my /etc/letsencrypt/ to the container's /etc/letsencrypt/, so they're symmetrical, and the certificates will be available inside the container too.
Now let's restart and bam, HTTPS! And without paying a single penny!
Before we say goodbye though, let's see...
3.2 - Some additional HTTPS configurations
These configurations aren't mandatory, but they will improve the overall security of the HTTPS server. They go in nginx.conf under the ssl_certificate and ssl_certificate_key directives
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS +RC4 RC4";
ssl_dhparam dh4096.pem;
You need the dh4096.pem file, which we'll generate in the nginx folder with the command
openssl dhparam -out dh4096.pem 4096
After a slightly long process (coffee break?) we'll get the file, and then we can restart the containers.
Now we've achieved Perfect Forward Secrecy, an extra security requirement to show off on the test sites!
UPDATE 2026: Obviously what I wrote about 7 years ago doesn't hold anymore. I put the updated top-notch configurations here (A+ score on the test sites): https://gist.github.com/giovanni-orciuolo/616527f1f01e70b3a026da0d56fe9f1d
That's all folks!
Well, we're done. This is everything we need to seriously ship a web-app with its server to production. Obviously the process can get more complicated if you have more than 2 projects and many more configurations to manage. Also remember that certificates must be requested for every subdomain!
See you next time!