Dockerizing Two Web-Servers To Respond To The Same Domain
If you have ever built a service which consisted of multiple loosely linked web servers, all responding to the same domain, and have wondered how you could dockerize them, then this tutorial is for you. If you have built such a project and are wondering why you should implement Docker, here are a couple of reasons:
- No more special dependencies for different operating systems. Figure out your installations once and be done with it.
- Write your setup code once and deploy it as often as you want to. No more repeating it manually on different servers.
- Keep a concise overview of all required services in your docker-compose file. Easily test if they work together locally.
So, if you’re still following, let’s hope you’ll learn something from this!
Setup
In this tutorial, we will be using two web servers. One will be running on the Python Django framework, whilst the other one will be an open-source Java project called Graphhopper. The Graphhopper project uses Open Street Map (OSM) data to calculate routes, provide geocoding and also a few other interesting features. An example use-case of this constellation would be a mobile app for e-scooter rentals. Whilst authentication and payment would perhaps be handled by the Django server, the route calculation from the user to the next scooter would be handled by the Graphhopper server.
Whilst the Django application will first have to be dockerized, the Graphhopper server is already a fully working Docker project. To handle which network requests will be handled by which web server, we will be using Nginx as a reverse-proxy.
This tutorial requires you to have Docker installed and that’s it. All further dependencies will be installed within the Docker containers/images. For more details, check out the code on Github. Lastly, this tutorial aims to provide code which will only require a single command at development & production start-up: docker-compose up --build
. This reduces the risk of accidentally forgetting crucial commands in a production environment.
Dockerizing Django Project
For the sake of compactness, this tutorial will try to bind all Docker services into one docker-compose file. In case you have an existing django project (“hello_django”) this may mean slightly re-structuring your code into the following setup:
.gitignore
django/
hello_django/
django_app1/
Dockerfile
manage.py
requirements.txt
entrypoint.sh # Explained later
prod.env
dev.env
nginx/
Dockerfile
docker-compose.yml
Dividing our project into subdirectories enables us to be very specific on what to build for every respective service in our docker-compose file. Also, the fact that docker-compose enables us to reference images on Dockerhub, we can avoid having to pack all our projects into a single git-repository. This is what can now be done with Graphhopper.
## ./docker-compose.ymlversion: '3.7'
services:
graphhopper:
image: graphhopper/graphhopper:latest
django:
build: ./django
expose:
- 8000
env_file:
- ./django/production.env
depends_on:
- graphhopper nginx:
build: ./nginx
depends_on:
- django
- graphhopper
Now, let’s have a look at our (simplified) Django Dockerfile. It is clear that we need one piece of software: Python. However, in Docker, we also need to choose our underlying operating system. Since it is good practice to build small Docker images, we will use an OS with very few pre-installed features: Alpine. And since using Alpine is very common for Docker, the Python registry on Dockerhub specifically offers pre-built Python-Alpine images. We will be using python:3.7-alpine. Nevertheless, since Alpine comes with no batteries included, you may also be required to install several dependencies via apk
(alpine package management). As these are project-dependent, they are not shown here.
Note that we are explicitly installing gunicorn with pip, as this will be the HTTP server wrapping our Django server. We are also creating a separate static directory, which will later be used by Nginx to serve static files more quickly.
## ./django/DockerfileFROM python:3.7-alpine
ENV APP_HOME=/home/hello_djangoRUN mkdir $APP_HOME
WORKDIR $APP_HOME
RUN mkdir $APP_HOME/staticADD ./requirements.txt $APP_HOME/requirements.txt
RUN pip install -r requirements.txt \
&& pip install gunicornADD . $APP_HOME
RUN chmod +x $APP_HOME/entrypoint.sh
ENTRYPOINT $APP_HOME/entrypoint.sh
Also note the ENTRYPOINT
. Defining an ENTRYPOINT
in the Dockerfile essentially defines what script is run when “docker run” is executed on the project and can be separated logically from the rest of the file, which defines the “docker build” process. It is not necessary to write an entrypoint script, but it can neatly wrap multiple commands if needed. In our case:
## ./django/entrypoint.sh
#!/bin/sh## Use this in case you are using a database server:
# echo "Waiting for database server..."
# while ! nc -z $DB_HOST $DB_PORT; do
# sleep 0.1
# done
# echo "Database server started"python manage.py migrate
python manage.py collectstatic — no-input — clear
gunicorn hello_django.wsgi:application — bind 0.0.0.0:8000exec “$@”
In contrast to scripts run with RUN
in the build process (which use arguments), ENTRYPOINT
scripts will have access to environmental variables added in the docker-compose file. Concerning collectstatic
— be sure to define the STATIC_ROOT
in the settings.py of Django. This is where our static files will be accumulated. In our case:
## ./django/hello_django/settings.pyBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # /home/hello_djangoSTATIC_ROOT = os.path.join(BASE_DIR, “static/”)
Integrate Open Source Web Server (Graphhopper)
This step is rather straightforward. Let’s look at a few lines of the Dockerfile of Graphhopper.
VOLUME [ “/data” ]
EXPOSE 8989
ENTRYPOINT [ “./graphhopper.sh”, “web” ]
CMD [ “/data/europe_germany_berlin.pbf” ]
To complement what I mentioned earlier about ENTRYPOINT
, CMD
has a similar functionality, only that it can either run as an independent execution or will be appended to the entrypoint, in case one is specified. In this case, we may want to overwrite the data that is fed to the Graphhopper webserver in docker-compose, by changing command
to data/ .. hamburg.pbf
. Also, but this is not strictly necessary, if we want to define the volume that Graphhopper will be using, we can do so in docker-compose as well. More to volumes later.
## ./docker-compose.ymlgraphhopper:
image: graphhopper/graphhopper:latest
command: [“/data/europe_germany_hamburg.pbf”]
volumes:
- graphhopper_data:/datavolumes:
graphhopper_data:
Communication between Web Servers
The two web servers are now already capable of communicating with one another. In this example, the Django web server may want to know the route from A to B. It can do so as follows:
import requests
url = f”http://graphhopper:8989/route?vehicle={mode}{points_string}"
r = requests.get(url)
The hostname to be used is the name specified as a service in docker-compose and the port to is taken from the Dockerfile of the Graphhopper project. Note that HTTPS will not be required here as it is not routed through Nginx.
Configure Nginx Reverse-Proxy
Our Nginx configuration will be somewhat special, as it will be aimed to be useable for development and production, without having to specify multiple files. In our docker-compose file we will define the ports as follows:
## ./docker-compose.ymlservices:
nginx:
ports:
# - HOST:CONTAINER
- 80:80
- 443:443
Whilst 80 is the standard port of HTTP, port 443 is the standard port for HTTPS. The host ports are ports we can access outside of our Docker daemon (e.g. in our web-browser) and the container ports are ports that are only accessible to other Docker services. To configure Nginx, we will create the following code structure:
django/
nginx/
Dockerfile
nginx.conf
graphhopper.conf
hello_django.conf
ssl.conf
Here, nginx.conf will be our main config file, which includes the other configs to keep the code DRY. The part dealing with the development configuration of our application will be at the top:
## ./nginx/nginx.confserver {
listen 80;
server_name graphhopper.localhost.io;
include /etc/nginx/graphhopper.conf;
}server {
listen 80;
server_name localhost hello_django.localhost.io;
include /etc/nginx/hello_django.conf;
}
By specifying the server_name to a subdomain of localhost, we make sure that the port will not be used by clients other than us locally. To make this configuration work however, we will have to adjust our /etc/hosts file on our actual machine by adding the following two lines:
## /etc/hosts127.0.0.1 hello_django.localhost.io
127.0.0.1 graphhopper.localhost.io
Now, when entering “hello_django.localhost.io” into our browser, the browser will not attempt to retrieve an IP Address from a DNS lookup server, but will redirect straight to 127.0.0.1 (localhost).
In the meantime, the graphhopper.conf looks like this:
## ./nginx/graphhopper.conflocation / {
proxy_pass http://graphhopper:8989;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
whilst our django.conf will looks like this:
## ./nginx/hello_django.conflocation / {
proxy_pass http://django:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}location /static/ {
root /home/hello_django;
}location = /favicon.ico {
access_log off;
log_not_found off;
}
Note that hereby, the Nginx Docker container will not also have to access the static files created by the Django container. We will achieve this later by using a volume.
Now, we will have a look at the part of the configuration responsible for production.
## ./nginx/nginx.confserver {
# Generic server for all HTTP requests in production
listen 80;
server_name www.your_domain.com your_domain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}}server {
server_name www.your_domain.com your_domain.com;
listen 443 ssl;
include /etc/nginx/hello_django.conf;
include /etc/nginx/ssl.conf;
}server {
server_name www.graphhopper.your_domain.com graphhopper.your_domain.com;
listen 443 ssl;
include /etc/nginx/graphhopper.conf;
include /etc/nginx/ssl.conf;
}
These server configurations are listening to SSL connections on port 443, essentially meaning HTTPS. This requires our last config file ssl.conf, which looks as follows.
## ./nginx/ssl.confssl_certificate /etc/letsencrypt/live/your_domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
If you are not familiar with how SSL keys/certificates work, as perhaps your current web-server is not running with HTTPS yet, here’s a short overview of the .pem-files we are referencing:
- fullchain.pem: This is a concatenation of cert.pem (our public key) and chain.pem (a certificate chain). The certificate chain has the purpose of proving that our public key actually belongs to us. It is a list of certificates, whereby every certificate has a public key and „signature“ of the next certificate in the list. Furthermore, every certificate is issued by a different Certificate Authority (CA), which also has a private and a public key. The „signatures“ on each certificate are the certificate’s public key encrypted by the private key of the next CA on the list. By decrypting the signature with the next CA’s public key, checking whether it results in the current certificate’s public key, we can validate that the next CA actually signed it. It is important here that the browser visiting your website trusts the CA that issued the last certificate on the list (“the root”), as this CA will sign it’s certificate itself.
- privkey.pem: This is our private key. This can decrypt anything that has been encrypted with our public key. The client can therefore establish it’s own secret key, encrypt it with our public key and be sure that only we can decrypt it. This leads the way to symmetric encryption, whereby both client and server use the same secret key to encrypt and decrypt. This is necessary since otherwise only the messages from client to server would be secret.
- ssl-dhparams.pem: These are our Diffie-Hellman parameters. These can be used as an alternative method of establishing a shared secret key between client and server.
These files amount to our “SSL-certificate”. In the next section we will go through how they are created. Following, you can see the Dockerfile for Nginx, which however does not feature anything out of the box. It is mostly important that we copy our main configuration into /etc/nginx/conf.d
for Nginx to work.
## ./nginx/DockerfileFROM nginx:1.17.4-alpine
ENV APP_HOME=/home/hello_djangoRUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
COPY graphhopper.conf /etc/nginx
COPY hello_django.conf /etc/nginx
COPY ssl.conf /etc/nginx# Create the appropriate directories
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
RUN mkdir $APP_HOME/static
RUN mkdir /var/www && mkdir /var/www/certbot
RUN mkdir /etc/letsencrypt
Automatic SSL Encryption Updates
Now it is time to create our .pem-files, which we referenced earlier. To do so, we will use “Let’s Encrypt”, which is a free, open and automatized Certificate Authority (CA). Let’s Encrypt offers a client, “Certbot”, that takes care of fetching SSL certificates from Let’s Encrypt and deploying them to the web server. To integrate Certbot, we will add another folder to our main repository…
.gitignore
django/
nginx/
certbot/
Dockerfile
setup.sh
docker-compose.yml
…and another service to our docker-compose file.
## ./docker-compose.ymlcertbot:
build: ./certbot
entrypoint: [“/bin/bash”, “/home/hello_django/setup.sh”]
environment:
DEV: “false”
As we will later see in our Dockerfile, this service will be based on the image certbot/certbot:v1.3.0 and has three tasks: creating a SSL certificate, regularly checking whether this certificate needs renewing and renewing it if it does. Since these certificates will be needed to be shared with Nginx, volumes will be required. However, volumes are only accessible for “docker run” and not “docker build”. Therefore, the certificates must be created during “docker run”. This is a problem: since the nginx.conf file has already specified where it’s certificates are placed, the nginx service will throw an error on run. To circumvent this problem, we can alter the docker-compose file as follows:
## ./docker-compose.ymlnginx:
restart: always
## or “on-failure”
With this configuration, nginx will reboot as often as is required, until certbot has created it’s certificates. These will be created in the setup.sh file. In this case, we will write the setup.sh file in bash (not sh), which requires bash to be added first with apk in the Dockerfile.
## ./certbot/DockerfileFROM certbot/certbot:v1.3.0ENV APP_HOME=/home/hello_djangoRUN apk update && apk add bash curlRUN mkdir -p /var/www && mkdir /var/www/certbot
RUN mkdir -p /etc/letsencrypt
RUN mkdir -p $APP_HOMECOPY ./setup.sh $APP_HOME
RUN chmod +x $APP_HOME/setup.sh
The setup.sh file is fairly long as it will be used for development and production. In development, SSL certificates are of course useless, since the HTTP requests do not leave your local machine and thereby don’t need encryption. However, we can easily write dummy keys as a placeholder. Let’s look at our file in parts:
## ./certbot/setup.sh ## PART 1#!/bin/bash# Original script: https://raw.githubusercontent.com/wmnnd/nginx-certbot/master/init-letsencrypt.shdomains=(your_domain.com www.your_domain.com)
rsa_key_size=4096
conf_path=”/etc/letsencrypt”
www_path=”/var/www/certbot”
email=”your_email@icloud.com”mkdir -p "$conf_path/live"
domain_path=”$conf_path/live/$domains”
mkdir -p “$domain_path”if [ ! -f “$conf_path/options-ssl-nginx.conf” ] || [ ! -f “$conf_path/ssl-dhparams.pem” ]; then
echo “### Downloading recommended TLS parameters …”
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > “$conf_path/options-ssl-nginx.conf”
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > “$conf_path/ssl-dhparams.pem”
fiif [ ! -z "$(ls -A $domain_path)" ]; then
echo “### Existing data found for $domains. Not replacing certificates.”
trap exit TERM; while :; do certbot renew; sleep 12h & wait $!; done;
fi
After initialising all folders typically used by Nginx to access its SSL-keys, we curl public Diffie-Hellman parameters and up-to-date TLS parameters. Then we check whether we have any existing SSL-certificates stored — if so, our job is done and we can run an endless while-loop where the certbot renews it’s certificate every 12 hours (checking for expiration first).
## ./certbot/setup.sh ## PART 2echo “### Creating dummy certificate for $domains …”
openssl req -x509 -nodes -newkey rsa:1024 -days 1\
-keyout “$domain_path/privkey.pem” \
-out “$domain_path/fullchain.pem” \
-subj “/CN=localhost”if [ $DEV == true ];
then
trap exit TERM; while :; do certbot renew; sleep 12h & wait $!; done;
fiecho "Wait for Nginx to start properly first time";
sleep 15s;# Nginx will cache it's certificates
echo "### Deleting dummy certificate for $domains ..."
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf
Independent of whether we want to be running on production or development, we will be needing dummy keys at first. This is because in production, Certbot will be running an “ACME-challenge”, where Let’s Encrypt sends Certbot a Token, which it will then expect to find under http://<your_domain>/.well-known/acme-challenge/<token>
shortly after. For this mechanism to work, Nginx has to be working— which it will when given dummy-keys. “Dummy keys” essentially refers to the fact that we are representing the “root” in the certificate chain of fullchain.pem and thereby sign our own certificate.
To control whether to continue or not, we will use an environmental variable DEV
, which we define in docker-compose. If we are in development, we don’t continue and instead run our endless while-loop to renew the certificates. If not, we can safely delete our dummy keys when Nginx has started, as they are cached in Nginx’ RAM.
## ./certbot/setup.sh ## PART 3echo “### Requesting Let’s Encrypt certificate for $domains …”domain_args=””
for domain in “${domains[@]}”; do
domain_args=”$domain_args -d $domain”
done# Select appropriate email arg
case “$email” in
“”) email_arg=” — register-unsafely-without-email”;;
*) email_arg=” — email $email” ;;
esaccertbot certonly — webroot -w $www_path \
$email_arg \
$domain_args \
— rsa-key-size $rsa_key_size \
— agree-tos \
— force-renewalsleep 30s; # Wait for Certbot to be finished
echo "Force restarting Nginx from Certbot"
echo -e "POST /containers/$NGINX_CONTAINER/restart HTTP/1.0\r\n" | nc -U /tmp/docker.socktrap exit TERM; while :; do certbot renew; sleep 12h & wait $!; done;
Now, we are finally using certbot to create our certificates. When this is done, Nginx needs to be rebooted, so it’s cache references the new, proper certificates. To do so, we are using the Docker API, which enables Docker services to communicate with one another via the Docker daemon. More specifically, we are targeting the Container-Restart endpoint of Nginx, so that Nginx receives the signal to restart. This however requires Nginx and Certbot to share the Docker daemon’s socket. This is done as follows:
## ./docker-compose.ymlnginx:
volumes:
- /var/run/docker.sock:/tmp/docker.sockcertbot:
volumes:
- /var/run/docker.sock:/tmp/docker.sock
To make sure Nginx is also updated when Certbot renews our certificates, we can also make Nginx reboot periodically. This works since the SSL certificates will be renewed before they expire — if Nginx does not restart right away, it will still use valid certificates for some time. To do so, we alter the Nginx service once more:
## ./docker-compose.ymlnginx:
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
This configuration now lets your system run well — however, if you want to be able to visit your sites with HTTPS during development, be sure that your machine trusts self-signed certificates. Also, it may in general seem as an overhead to create a Certbot container that will be sleeping most of the time, as this could also be dealt with a crontab on the server with docker-run certbot ...
, but it simply eliminates manual setup steps.
Use Volumes to Persist Data
Let’s finally talk about how to persist memory and share it across the services. Our volume configuration looks as follows:
## ./docker-compose.ymlservices:
django:
volumes:
- static_data:/home/hello_django/static
graphhopper:
volumes:
- graphhopper_data:/data
nginx:
volumes:
- static_data:/home/hello_django/static
- ssl_data_conf:/etc/letsencrypt
- ssl_data_www:/var/www/certbot certbot:
volumes:
- ssl_data_conf:/etc/letsencrypt
- ssl_data_www:/var/www/certbotvolumes:
static_data:
graphhopper_data:
ssl_data_conf:
ssl_data_www:
Whilst static_data is shared between nginx and django, ssl_data_conf and ssl_data_www are shared between nginx and certbot. All data that is created in one service during run-time, will be accessible for the other sevice, where it may however be mapped to a different directory (as it is a different container). In terms of persistence, this also allows e.g. certbot to check in the volume first, whether any certificate has been created in any previous run-time session. This is especially important for Graphhopper, since it first needs to analyze the OSM data and saves this into the folder ./data. This can sometimes take hours and should not be repeated for every launch.
Development vs. Production
As you may perhaps know already, using a docker-compose.override.yml file is a fairly easy way of overwriting production settings with local ones. This would of course also imply adding docker-compose.override.yml to .gitignore. In this example, we take advantage of this feature to overwrite environmental variables and re-adjust the restart configuration. Note that in the original docker-compose file, we would have specified “restart” to “always” for all services, as that would be used in production.
## ./docker-compose.override.ymlservices:
django:
env_file:
- ./django/development.env
graphhopper:
restart: “no” nginx:
restart: “on-failure” certbot:
environment:
DEV: “true”
NGINX_CONTAINER: "shnake_nginx_dev"
restart: “no”
Now, as promised in the beginning, your application should be entirely executable, using only the following command:
docker-compose up --build
That was it — I hope you enjoyed the tutorial and perhaps learnt something from it, also if it was very specific. Thanks for reading!
Edit:
- One thing that may however pose a security risk, is not regularly updating your OS and your Docker images. For this, we may actually have to configure a crontab manually on the server. The command to update already running docker containers is
docker-compose pull && docker-compose up -d
.