Hosting multiple sites or applications using Docker and NGINX reverse proxy with Letsencrypt SSL
So I decided to rehost my homepage and a couple other web pages and apps on a new server. And since we’ve been using Docker and Compose for some projects at work, I thought, hey, this could be a neat clean setup for multiple apps hosted on a single machine without installing a ton of local dependencies and managing everything by hand. Here’s the story of how this went down, what I learned along the way, and how you can build the same setup without doing the same mistakes that I initially did.
Getting the hardware
First thing you need to run anything on the web is a server of some sort. Yes, yes, serverless is all the rage right now, but we’re not going there.
There are all sorts of server setups available for all sorts of purposes and with all sorts of price tags. As a first step, I compiled a basic list of requirements for the kind of hosting I thought I’d need. It would be serving mostly static pages, with a few more dynamic apps with some background processes doing data collection and talking to some APIs, but there’d be no significant processing or other heavy lifting in there. So these were the basic min specs for the server I came up with:
- single CPU (won’t be doing anything CPU-intensive, so why have more)
- 2-4GB RAM (same reasoning)
- 20GB or more storage (I’ll need to serve some static content, so less than that won’t do)
- IPv6 enabled (necessary for some of my projects that I might tell about at some point)
- recent Linux kernel that’ll support Docker properly (surprisingly common issue with cheap VPS setups, as I found out)
After researching the market a bit, I arrived at the conclusion that an entry-level VPS at a local provider here in Germany is probably what I want. There are many providers available out there and they change up their offerings rather fast, so I won’t name any specific ones here - hit me up on Twitter if you want to know which one I ended up with.
A noteworthy alternative to a VPS is Amazon EC2. I actually used their free-tier t2.micro
instance for a while to test
an early iteration of this very setup, and it did the job perfectly. Of course, those are only free for 12 months - but if you
just want to test the setup and possibly move to a different server later (or continue running on EC2, although that’s considerably
more expensive), it’s something I can recommend.
DNS
You can run the applications under any domains or subdomains you like, provided their DNS is pointed to the server you have set up.
I personally run all applications as subdomains (and root domain) of olex.biz. The DNS is setup with A records for olex.biz
and *.olex.biz
pointing directly to the server’s IP. This way I can choose any subdomains for new apps I want to deploy, and
don’t have to update DNS records every single time.
Setting up the software
Base OS and Docker
For my VPS, I chose to run an Ubuntu 18.04 image offered by the VPS provider. You may choose whichever distro you’re familiar with - the only requirement is that it can run Docker. Get it installed and configured as you would configure any Internet-facing server - SSH with public key auth only, no root login, fail2ban, the usual setup. Important thing is at the end you have a working server with Docker and Compose available.
NGINX reverse proxy and SSL
The NGINX reverse proxy is the key to this whole setup. Its job is to listen on external ports 80 and 443 and connect requests to corresponding Docker containers, without exposing their inner workings or ports directly to the outside world. Additionally, with the SSL companion container the proxy also automatically redirects all HTTP requests to HTTPS and handles SSL encryption for all traffic, including certificate management.
For this job, I use jwilder/nginx-proxy and
docker-letsencrypt-nginx-proxy-companion.
My docker-compose.yml
for them is placed in /home/olex/proxy
and looks like this:
version: '2'
services:
nginx-proxy:
image: jwilder/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- conf:/etc/nginx/conf.d
- vhost:/etc/nginx/vhost.d
- dhparam:/etc/nginx/dhparam
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy
restart: always
letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
container_name: nginx-proxy-le
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: always
volumes:
conf:
vhost:
dhparam:
certs:
networks:
proxy:
external:
name: nginx-proxy
Let’s go through some details here to understand what’s going on. There’s two services, nginx-proxy
and letsencrypt
, as
well as some volumes and a custom external network specified here.
nginx-proxy
nginx-proxy
has a couple things happening:
Its ports 80 and 443 are forwarded to the host, making it Internet-facing. No other containers we run on this machine will need their ports forwarded, all communication from and to the outside will be proxied through here - hence “reverse proxy”.
Various NGINX configuration directories are mounted as named volumes to keep them persistent on the host system. Those volumes are defined further down in the file.
/var/run/docker.sock
from the host is mounted. This allows the proxy to listen in to other containers starting and stopping on the host, and configure NGINX forwarding as needed. Containers need to present their desired hostnames and ports as environment variables that the proxy can read - more on that further below.Finally, the container is assigned to a
proxy
external network, which is described below.
letsencrypt
letsencrypt
is a companion container to nginx-proxy
that handles all the necessary SSL tasks - obtaining the required
certificates from Let’s Encrypt and keeping them up-to-date, and auto-configuring nginx-proxy
to transparently encrypt
all proxied traffic to and from application containers.
It’s connected to nginx-proxy
by sharing its volumes (volumes-from:
directive). It also listens in on the host’s
/var/run/docker.sock
to be notified when application containers are started, and to get the information from them to obtain
the necessary SSL certificates.
Note that this container doesn’t need to be put in the external network - it gets by only using the shared volumes and Docker socket, and never needs to talk to the outside world or another container directly.
External network
Last but not least, there’s the external network. To understand why you might need it, you need to know how docker-compose handles networks by default:
For every application that is run using its own
docker-compose.yml
, Compose creates a separate network. All containers within that application are assigned only to that network, and can talk to each other and to the Internet.We want to deploy multiple applications on this server using Compose, each with their own
docker-compose.yml
, and proxy them all to the outside world via ournginx-proxy
container.No other container can access containers within a default network created by docker-compose, only those inside the application’s own
docker-compose.yml
. This makes life difficult fornginx-proxy
.To work around this, we create a single network outside of Compose’s infrastructure, and place our
nginx-proxy
container in that network. To do this, we need to define this network asexternal
in thedocker-compose.yml
- this way Compose will not try to create the network itself, but just assign the containers it creates to the existing outside network.If we try to run this as is, Compose will error out telling us the external network doesn’t exist.
We need to initially create the proxy network manually with:
sudo docker network create nginx-proxy
Now we can run our proxy container and SSL companion:
sudo docker-compose up -d
And continue with deploying at least one application behind the proxy to see that it actually works.
Running applications behind the proxy
For various application I run on the server, I create separate directories in /home/olex
. Let’s look at one of the simplest
possible apps: a web server with some static content.
Single-container applications
To run such a server, one could use Apache, Lighttpd or another Nginx. I prefer to use things I know, and Nginx is plenty small
and fast as a static content server, so that’s what I go with. Here’s the docker-compose.yml
for such an “application”, if you
can even call it that:
version: '3'
services:
nginx:
image: nginx:alpine
expose:
- "80"
environment:
- VIRTUAL_HOST=static.domain.tld
- VIRTUAL_PORT=80
- LETSENCRYPT_HOST=static.domain.tld
- LETSENCRYPT_EMAIL=email@somewhere.tld
volumes:
- /home/olex/static/files:/usr/share/nginx/html:ro
networks:
- proxy
restart: unless-stopped
networks:
proxy:
external:
name: nginx-proxy
Let’s go through this point by point.
There’s a single service,
nginx
, running the tinynginx:alpine
image. We don’t need more than that to serve some static content.There are no ports forwarded to the host, but port 80 is exposed - this is necessary so
nginx-proxy
can forward to it.Environment variables set all configuration values needed by
nginx-proxy
andletsencrypt
:VIRTUAL_HOST
tellsnginx-proxy
under which domain should this container be reachable.VIRTUAL_PORT
tellsnginx-proxy
which port to forward requests to. In this case it’s 80 as is usual for a HTTP server, it could be any other port - e.g. various Node.js apps typically serve up on port 3000.LETSENCRYPT_HOST
tellsletsencrypt
that this container’s traffic should be SSL encrypted, and which domain to request a Let’s Encrypt certificate for. It is a bit redundant toVIRTUAL_HOST
(I can’t think of a situation where you’d want them to be different), but that’s how the proxy containers work.LETSENCRYPT_EMAIL
is used to tell Let’s Encrypt where to send certificate expiration notices. Typically this never happens, becauseletsencrypt
auto-renews certificates as necessary. If you get such an email and you haven’t taken the service down, you’ll at least know something’s not working properly.
/home/olex/static/files
host directory is mounted to the default server root location insidenginx
in read-only mode, allowing it to serve those files but not modify them (one can never be too careful).Container is assigned to the same external network as used above. This allows
nginx-proxy
to forward requests despite this being a separate Compose application, that would normally be completely isolated from other containers on the host.
Multi-container applications
Most applications you might want to deploy consist of several containers - say, a database, a backend service that does some processing,
and a frontend. Of those containers, you may only want the frontend to be accessible from the outside, but the three should be able
to talk to each other internally. A docker-compose.yml
for such a setup might look something like:
version: '3'
services:
frontend:
image: frontend-image:latest
expose:
- "3000"
environment:
- VIRTUAL_HOST=awesomeapp.domain.tld
- VIRTUAL_PORT=3000
- LETSENCRYPT_HOST=awesomeapp.domain.tld
- LETSENCRYPT_EMAIL=email@somewhere.tld
- BACKEND_HOST=backend
networks:
- proxy
- app
backend:
image: backend-image:latest
environment:
- DB_HOST=db
networks:
- app
db:
image: postgres:11
volumes:
- /opt/app-db:/var/lib/postgresql/data
networks:
- app
networks:
proxy:
external:
name: nginx-proxy
app:
Things of note here:
Only the
frontend
container is put into theproxy
external network, because it’s the only one that needs to talk tonginx-proxy
. It’s also the only one with the environment variables for proxy configuration. The containers that are only used internally in the app don’t need thosebackend
anddb
containers are put in a separateapp
network, that is defined additionally undernetworks
. This serves in place of a default network that Compose would normally create for all containers defined in thedocker-compose.yml
- except once you define any network manually, the default network doesn’t get created anymore, and so ourbackend
anddb
containers would be completely isolated. With theapp
network, they can talk to each other and tofrontend
normally.
As you can see, the setup is not very complicated. The only things to add to your normal docker-compose.yml
of any multi-container
app are the networks and environment variables for the frontend container.
Multiple proxied frontend containers
You can have multiple containers within a single Compose application that you want to make available via the proxy - for example,
a public frontend and an administrative backoffice. You can configure as many containers inside one docker-compose.yml
with
the proxy
network and necessary environment variables as you need. The only limitation is that every container needs to run
on a separate VIRTUAL_HOST
.
For example, you could run your public frontend at awesomeapp.domain.tld
and the admin backend to that at admin.awesomeapp.domain.tld
.
In conclusion
I’ve been running this setup in various forms for a while now, and it seems to do its job admirably. Adding new applications
is as easy as writing a docker-compose.yml
and running docker-compose up -d
- all configuration happens automagically and
fairly quickly, and my shiny new app/website/whatever is quickly available and SSL secured. There are also fairly significant
custom configuration options supported by nginx-proxy
that I haven’t delved into in this introductory article - if you’re
interested, go through their README, it’s all fairly easily done when needed.
I hope this could help someone aside from myself. Hit me up on Twitter if you found this guide helpful, or if you have any comments at all.
Also check out the follow-up post, where I describe how I use
this setup in conjunction with GitLab CI to quickly deploy my various side projects with a single git push
.