Self-Hosting Handbook: A Docker-Compose Tutorial

Welcome to the second page of a handbook on self-hosting. Begin here.
Topics covered on this page
- How a reverse proxy works and why you need one
- Creating and understanding docker-compose files
- Fleshing out your docker-compose file
- Hitting the "up -d" button on your self-hosted stack
What's the BEST DEAL in cloud hosting?
Develop at hyperspeed with a Performance VPS from SSD Nodes. We DOUBLED the amount of blazing-fast NVMe storage on our most popular plan and beefed up the CPU offering on these plans. There's nothing else like it on the market, at least not at these prices.
Score a 16GB Performance VPS with 160GB of NVMe storage for just $99/year for a limited time!
How a reverse proxy works and why you need one
A reverse proxy is a web server that accepts incoming traffic, analyzes it, and directs it to the appropriate backend service/server. Reverse proxies are often used as load balancers or caching servers, but for the sake of this handbook, we're mostly interested in how a reverse proxy can accept traffic from multiple domains, all directed toward the same VPS, and deliver the appropriate content in turn.
Let's look at this example diagram:
ssdnodes.com ----| |-- LAMP stack + PHP files
| reverse proxy |
x.ssdnodes.com --|--- @ ---|-- WordPress blog
| 123.456.78.90 |
y.ssdnodes.com --| |-- Node.js web app
In this case, we have three domains—ssdnodes.com
, x.ssdnodes.com
, and y.ssdnodes.com
—that are all pointed toward a VPS at the IP address 123.456.78.90
. We also have three backend services—a LAMP stack, a WordPress blog, and a Node.js web app—that we want to serve.
When someone types one of those three domains into their browser's navigation bar, they send a request to the VPS via DNS (remember DNS from the previous page?). The reverse proxy receives this request, analyzes it, and recognizes that the request is looking for ssdnodes.com
. The reverse proxy then sends a request to the LAMP stack, which returns an HTML page. Finally, the reverse proxy "forwards" that HTML page (proxies it, actually, hence the name) to the user's browser.
When they type x.ssdnodes.com
into their browser, the reverse proxy returns the WordPress blog, and when the type x.ssdnodes.com
into their browser, they get the Node.js app in return.
In this way, a reverse proxy allows you to host multiple websites or web apps from a single VPS.
I consider a reverse proxy necessary to self-hosting because it's inconvenient enough to remember the IP address of your VPS, much less the various ports on which each of your services will be running. Also, the reverse proxy will request and serve the Let's Encrypt SSL certificate that will allow you to have HTTPS-enabled websites and apps.
Why is SSL important? Expand
SSL, which stands for Secure Sockets Layer, is the standard technology for encrypting traffic between a browser and web server. SSL is the encryption standard your bank's website, for example, will use to ensure that no one can "sniff" your traffic and discover your bank account number, password, or other identifying information.
In the past, SSL was used primarily on websites that stored personal information about its users, like bank accounts, but also email, social media, e-commerce, and others. Many small sites, like personal blogs, didn't use SSL, as it was difficult to set up and often cost hundreds of dollars per year.
Enter Let's Encrypt, which delivers free, automated SSL certificates to anyone with a valid domain name (like example.com
). A Let's Encrypt certificate allows you to encrypt the traffic entering and leaving the self-hosting stack on your VPS.
No matter what you'll ultimately be hosting on your VPS via this handbook, SSL is critical. If you're going to host a website or blog, for example, Google will severely dock your SEO scores without SSL enabled. If you have any personal information stored on your VPS, SSL will help prevent anyone from reading that information. Even if you're only self-hosting a private Git server like Gitea, SSL ensures full encryption between your server and your browser.
Creating and understanding docker-compose files
Now that you have some context for what we're trying to build with this self-hosting stack, it's time to take a look at the primary tool we're going to use moving forward: docker-compose
.
You installed this program on the previous page. docker-compose
is a "wrapper" around the docker
command-line program itself, and uses configuration files called docker-compose.yml
to launch multiple Docker containers instead of typing out long commands with docker
.
Log into your VPS if you're not already, create a new proxy
directory in your home directory, and cd
into it.
$ mkdir -p ~/proxy
$ cd ~/proxy
Create a new file using the editor of your choice. I use nano
for these kinds of tasks, but you may prefer to use vim
or emacs
instead. Just replace nano
with your editor of choice from here on out.
$ nano docker-compose.yml
Let's start with building out the "skeleton" of a docker-compose.yml
file, which will then allow us to talk about how each component works, and the syntax behind it. Inside the new file, type in the following.
version: '2'
services:
volumes:
networks:
Explanation: version
As docker-compose
expands and becomes more feature-rich, it needs to evolve and adopt new ways of explaining how users can deploy a Docker-based infrastructure. The maintainers infrequently introduce a new version of the syntax to accommodate these adaptations. We're sticking with the '2'
syntax, even though there's already a third version version: '3'
available already.
Version 3 introduces a new way of describing volumes that I haven't gotten working precisely the way I'd like yet. I'll be sure to update this guide when I have a version 3-compatible version of this docker-compose.yml
file ready.
Explanation: services
The services
section defines which Docker containers you'll launch when you finally do run docker-compose
. It's the most important section we'll cover in on the rest of this page in the self-hosting handbook!
We will define each service in a separate indented "block." When we run docker-compose
, it will iterate over these services blocks and download/create/launch each Docker container in sequence.
Explanation: volumes
Volumes are essentially folders that get shared between your user's filesystem and the Docker container's filesystem. Think of it as a symbolic link. Or, if you're not too familiar with those, think about it as a Dropbox folder on your current machine. You put something in that folder, and it appears on the Dropbox web interface, or on another device of yours—the folders are synchronized.
Because volumes are synchronized, you can define various configuration files and make changes to them without diving into the Docker container's filesystem, which can be quite tricky.
You can also create a single volume that's shared among many Docker containers, such as using one MySQL database for five different WordPress installations. But that's, well, not recommended. Not by a long shot.
Explanation: networks
The networks
section in a docker-compose
file allows you to define internal networks through which different Docker containers can communicate. We're going to keep it simple throughout this handbook, with only a single network, but more complex infrastructures might require multiple internal/external networks.
Fleshing out your docker-compose file
Time to build out the docker-compose.yml
file!
Under the services
block, add your first service, which we're going to call proxy
. Remember that indentation is important with docker-compose
files—the services
label should be aligned fully to the left, while each defined service is indented once, and its contents are indented twice or three times.
services:
proxy:
image: jwilder/nginx-proxy
container_name: proxy
restart: unless-stopped
labels:
com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost.d:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- ./uploadsize.conf:/etc/nginx/conf.d/uploadsize.conf:ro
ports:
- "80:80"
- "443:443"
networks:
- "default"
- "proxy-tier"
Let's take a minute to look at what this services
block accomplishes.
proxy
The first line of the block defines a new Docker container. You can name them however you'd like, but let's keep it simple.
image: jwilder/nginx-proxy
This line defines the Docker image that we're going to launch—in this case, the wonderful nginx-proxy image.
container_name: proxy
We're going to name the container proxy
in this case, and refer to it as that moving forward, but you can name it however you'd like.
labels:
com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true"
This nested labels
line creates a label so that the Let's Encrypt container, which we'll launch in a moment, knows which proxy to connect to.
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost.d:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
Here we create some volumes for our proxy
Docker container. First, we connect the Docker .sock
, followed by three additional volumes that create those "synchronized" folders between the proxy
folder we created earlier and various folders inside of the container.
ports:
- "80:80"
- "443:443"
We're now defining which ports the Docker container will listen on. By listening on ports 80
and 443
, the proxy
container will accept all web traffic arriving on your VPS, which is exactly what we want—the reverse proxy needs to receive all the traffic so that it can route it correctly.
networks:
- "default"
- "proxy-tier"
Finally, we're defining which networks the proxy
container will communicate through. The default
network will handle traffic entering and exiting the VPS, whereas the proxy-tier
network allows all the containers in our self-hosting stack to communicate with one another.
Let's Encrypt container
Time to add the Let's Encrypt container to the file.
proxy-letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
container_name: letsencrypt
restart: unless-stopped
environment:
- NGINX_PROXY_CONTAINER=proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes_from:
- "proxy"
depends_on:
- "proxy"
networks:
- "default"
- "proxy-tier"
There are a few new configurations here, namely environment
, volumns_from
and depends_on
. All these help the Let's Encrypt container find and connect to the proxy
container we just specified. You shouldn't need to edit these, so we won't spend time discussing the nuances of how they work.
Portainer container
I like to add a Portainer container to any self-hosting stack as a means of monitoring my containers and doing some basic administrative work that's much more difficult using the many options and flags of the docker
command line tool.
As a bonus, this portion of the docker-compose.yml
file will serve as an example of how we'll eventually set up other self-hosted services.
Time to add one last block to our services
section.
portainer:
image: portainer/portainer
container_name: portainer
restart: always
environment:
- VIRTUAL_HOST=docker.DOMAIN.TLD
- LETSENCRYPT_HOST=docker.DOMAIN.TLD
- LETSENCRYPT_EMAIL=EMAIL
volumes:
- ./portainer/:/data
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "9000:9000"
Take a look at the environment
block here, as it's critical to how this entire stack functions, from the reverse proxy to how Docker containers communicate with one another. You will put a similar block in every new container you add to the services
section of your docker-compose.yml
file.
There are three essential environment variables to set here: the VIRTUAL_HOST
, the LETSENCRYPT_HOST
, and the LETSENCRYPT_EMAIL
.
The LETSENCRYPT_EMAIL
variable is simplest to explain: Replace the EMAIL
variable with your email.
The VIRTUAL_HOST
and LETSENCRYPT_HOST
both reference the domain at which you'll access the Portainer service using your browser. Both these variables must be set on every Docker container you want to access via the reverse proxy. On the previous page, you set up a DNS entry that pointed toward docker.DOMAIN.TLD
. Here's where that DNS entry comes in. Flesh out both those entries with your domain name.
Volumes and networks
Finally, add the following to your docker-compose.yml
file:
volumes:
certs:
vhost.d:
html:
networks:
proxy-tier:
We won't spend too much time on these, as they don't need to be edited and will never change, but they set up a number of volumes and a single proxy-tier
network that allows containers to communicate amongst themselves.
The full file, for reference
Knowing that copy-pasting or re-typing configuration files is a rather error-prone process, here is the full file for your reference:
version: '2'
services:
proxy:
image: jwilder/nginx-proxy
container_name: proxy
restart: unless-stopped
labels:
com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost.d:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- ./uploadsize.conf:/etc/nginx/conf.d/uploadsize.conf:ro
ports:
- "80:80"
- "443:443"
networks:
- "default"
- "proxy-tier"
proxy-letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
container_name: letsencrypt
restart: unless-stopped
environment:
- NGINX_PROXY_CONTAINER=proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes_from:
- "proxy"
depends_on:
- "proxy"
networks:
- "default"
- "proxy-tier"
portainer:
image: portainer/portainer
container_name: portainer
restart: always
environment:
- VIRTUAL_HOST=docker.DOMAIN.TLD
- LETSENCRYPT_HOST=docker.DOMAIN.TLD
- LETSENCRYPT_EMAIL=EMAIL
volumes:
- ./portainer/:/data
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "9000:9000"
volumes:
certs:
vhost.d:
html:
networks:
proxy-tier:
If you copy-paste this entire file, you must still replace the EMAIL
, DOMAIN
, and TLD
variables before moving forward.
Allowing for big file uploads
Before you can launch the stack, create one more file in your proxy
directory called uploadsize.conf
. Inside of it, add a single line with the following: client_max_body_size 1g;
.
Our reverse proxy will link this file to the nginx
configuration and allow you to upload files up to 1GB in size to your self-hosting stack. Very useful when using a file synchronization service like Nextcloud.
Hitting the "up -d" button on your self-hosting stack
Now that the docker-compose.yml
file is finished, it's time to run docker-compose
for the first time. Type the following into your VPS' terminal:
$ docker-compose up -d
Hit Enter
, and you'll see quite a bit of output as Docker downloads the images. If all goes correctly, you should see the following as the process ends:
Creating network "proxy_default" with the default driver
Creating network "proxy_proxy-tier" with the default driver
Creating portainer ... done
Creating proxy ... done
Creating letsencrypt ... done
Let's do a quick check of our self-hosted stack to make sure that everything is running correctly. Run docker ps
and you should see the following:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bf830886f0a2 jrcs/letsencrypt-nginx-proxy-companion "/bin/bash /app/entr…" 3 minutes ago Up 3 minutes letsencrypt
5d0845395978 jwilder/nginx-proxy "/app/docker-entrypo…" 3 minutes ago Up 3 minutes 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp proxy
10bd13114c5d portainer/portainer "/portainer" 3 minutes ago Up 3 minutes 0.0.0.0:9000->9000/tcp portainer
The output shows that nginx-proxy
, the Let's Encrypt helper, and Portainer are all running.
Open up a browser and navigate to docker.DOMAIN.TLD
. You should see the Portainer setup process—create an administrative account, log in, and you should be able to see all your containers running in the nice web UI.
If you made it this far, congratulations! You're already 75% of your way to having a fully-functional self-hosting stack on your VPS.
From here on out, I'll explain how Let's Encrypt is working, why that's important, and how you can add more containers. After that, I'll start creating a small "database" of example services
blocks for you to pick and choose from.
Bookmark this guide and follow us on Twitter or Mastodon to get updates. Or, you can subscribe to the weekly Serverwise newsletter, where I’ll let you know as soon as this guide expands.
[cta]Table of contents
-
Self-hosting with Docker: The definitive handbook
-
Self-Hosting Handbook: A Docker-Compose Tutorial
-
Using docker-compose to add web apps: The self-hosting handbook
-
Self-hosting administration: The self-hosting handbook
-
Self-Hosting Nextcloud with Docker: Self-hosting handbook
-
Smart VPS monitoring with NetData and Docker
-
Docker Networking — Done the Right way!
Like what you saw? Subscribe to our weekly newsletter.