The Raspberry Pi Project: Part 2 - Web Server
So as I mentioned in my previous post, I wanted to use my Raspberry Pi as a webserver so that I could play around with web development projects. More specifically, I wanted to run NGINX, with autogenerated SSL certs from Let's Encrypt, and I wanted a Dynamic DNS address so that I didn't have to deal with changing IP addresses provided by my ISP. Oh, and I wanted this to all be described by one simple docker-compose.yml
file.
Installing Prerequisites
First things first, you'll need to get a Raspberry Pi up and running with Raspbian. There's plenty of guides on this so if you have know idea, do some googling.
Next, we need to install Docker & Docker Compose. We'll start with Docker.
curl -fsSL get.docker.com -o get-docker.sh && sh get-docker.sh
Next, I'd recommend adding yourself to the Docker group so that you don't have to use sudo
every time you want to run a Docker command.
sudo usermod -aG docker pi
Next, update your packages.
sudo apt update && sudo apt upgrade
Now we shall enable the Docker service so that it automatically starts up on boot.
sudo systemctl enable docker
Last but not least, let's install docker-compose
sudo apt install docker-compose
T
o make sure everything is working properly, let's run the infamous hello-world
container!
docker run hello-world
You should see Docker look for the hello-world
container locally, fail to find it, pull it from the Docker Registry, run it and then display the result in the terminal! If you see this, your Docker installation is working!
Docker & Docker Compose
So what is Docker? Well, I won't regurgitate what 1000 other blogs have already explained far better than I ever could, so instead, here's the TLDR:
Docker lets you package apps and all their dependencies into 'containers' that can be downloaded and run on any platform that supports Docker.
Docker Compose?
Docker compose lets you write down how to run a container(s), so that you don't have to write it all out in the command line every time you wanna boot it up.
It's more complicated than that, but if you want to find out more, I won't stop you.
So rather then explain how Docker Compose files work in general, I figured I'd walk you through my docker-compose.yml
file, because I learn best from examples!
DuckDNS
So here is a snippet of my compose file that I use to build my web server. This part in particular is used to run my DuckDNS client, so that my dynamic DNS address will always stay up to date. It's a fairly straight forward example, so let's take a look.
version: '3'
services:
duckdns:
image: linuxserver/duckdns:arm32v7-latest
container_name: duckdns
environment:
TZ: "Australia/Brisbane"
TOKEN: "${DUCKDNS_TOKEN}"
SUBDOMAINS: 'deluqs'
PUID: 1000
GUID: 1000
LOG_FILE: 'true'
volumes:
- './duckdns/config:/config'
restart: unless-stopped
To start with, I declare which version of docker-compose I am using. At the time of writing, 3 is the latest, so that is what I am using. Next I declare the services I want to run, which in this case, is just one service I've called 'duckdns'.
I then tell Docker to use the 'linuxserver/duckdns:arm32v7-latest' image, which just describes which container I want to run. More specifically, look for the 'duckdns' image by the Docker Hub account 'linuxserver', with the tag 'arm32v7-latest'. Note that it is important to declare the use of the 'arm32v7-latest' tag, since the Raspberry Pi 4 runs on arm32v7 architecture. Remember this, because I will bring it up again later.
Next I give the container the name 'duckdns', as well as a bunch of environment variables. To see the explanation behind each of these environment variables, see the linuxserver/duckdns documentation. It is also worth noting that in the same directory as the docker-compose.yml
file, I have a .env file that contains my DUCKDNS_TOKEN
, which will get substituted in at runtime.
Next, I tell Docker to mount the /config
folder within the duckdns container to the folder ./duckdns/config
on my Pi. This allows me to easily see the duck.log file from the host, as it gets written to /config/duck.log
within the container.
Finally, I make sure that the container always restarts, unless I've manually stopped it. This makes sure that it will automatically start on boot.
NGINX
Next, the container I use to run NGINX.
version: '3'
services:
duckdns:
...
nginx-proxy:
image: aidanstansfield/nginx-proxy:arm32v7
container_name: nginx-proxy
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:ro
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- conf:/etc/nginx/conf.d
- dhparam:/etc/nginx/dhparam
environment:
DEFAULT_HOST: "deluqs.duckdns.org"
volumes:
conf:
vhost:
html:
dhparam:
certs:
Now, there's a couple of differences between this and the DuckDNS example above. The most obvious one, is that it's a different image (duh). The image I'm using is a customized version of jwilder's nginx-proxy that I built myself, with support for the arm32v7 architecture.
NOTE: I plan on doing a blog post on why and how I built this container in the future, so keep your eyes peeled!
If you aren't familiar with jwilder's nginx-proxy, what it does is observe the Docker containers you run, and if any have the environment variable VIRTUAL_HOST
, it will automagically setup an NGINX proxy and point it to that container!
For instance, say I spin up a container of some kind that serves web content out of port 80. If I add the environment variable VIRTUAL_HOST: "deluqs.duckdns.org"
to that service, then NGINX will see that and setup the necessary config files to proxy any requests for the host "deluqs.duckdns.org" to port 80 within that container. See the pieces starting to come together?
The next difference with the docker-compose.yml
is that I've declared some port bindings. These simply mean listen to this port on the host:pass it to this port in the container
. So any traffic that hits port 80 on my Pi will be forwarded to the nginx-proxy container's port 80.
The final difference is the volumes! Notably, you will see that I've mounted a special /var/run/docker.sock:/tmp/docker.sock:ro
volume, as well as some other named volumes like html:/usr/share/nginx/html
. The first volume is special as it allows communication between the container and the docker daemon running on the host, and the :ro
specifies it is read-only.
The named volume is similar to the ones we described earlier, but we're letting docker decide where to store the volume on the host, and we're giving it a name so that we may reference it later as you shall soon see. Note that named volumes need to be declared at the global level, as seen at the bottom of the compose file.
Let's Encrypt
Finally, the magic that turns SSL on for all nginx-proxy sites.
version: '3'
services:
duckdns:
...
nginx-proxy:
...
letsencrypt-nginx-proxy-companion:
image: aidanstansfield/docker-letsencrypt-nginx-proxy-companion:arm32v7
container_name: le-nginx-proxy-companion
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- conf:/etc/nginx/conf.d
- dhparam:/etc/nginx/dhparam
environment:
DEFAULT_EMAIL: "aidan.stansfield@gmail.com"
NGINX_PROXY_CONTAINER: "nginx-proxy"
volumes:
conf:
vhost:
html:
dhparam:
certs:
This has nothing you haven't seen before, but I'm including it for completeness sake. You'll notice that again, I'm using an image that I have built myself, based upon jrcs' docker-letsencrypt-nginx-proxy-companion, so that it supports the arm32v7 architecture.
Notice that we're using the same named volumes as the nginx-proxy service, and that allows these two seperate services to both access the same files.
Summary
So that's how I run my DuckDNS, NGINX and Let's Encrypt services on my Raspberry Pi with just a single docker-compose up
. Note that I never have to touch NGINX config files, or deal with setting up SSL, all I need to do is pass in an environment variable or two into any container I spin up and it's all handled for me, how neat. Hope you found some of this useful!