Using docker-compose to add web apps: The self-hosting handbook

By Joel Hans

Welcome to the third page of a handbook on self-hosting. Begin here. On this page, we’ll be using docker-compose to add web apps and containers to our self-hosting stack.


Topics covered on this page

  1. A recap of environment variables and ports
  2. Example: Adding Gitea to your self-hosting stack
  3. Example: Adding FreshRSS to your self-hosting stack
  4. How does this actually work?

You’re being overcharged for your cloud computing.

We fixed that. For a limited time, during our 7-year anniversary sale, save $2,592 with a 16GB RAM server. Deploy with honest value from your cloud hosting provider. ⚡

Get limited-time pricing:

A recap of environment variables and ports

On the previous page of this self-hosting handbook, we walked through your docker-compose.yml file and I tried to explain how to specify different environment variables that will both allow our orchestration containers to grab SSL certificates from Let’s Encrypt, and allow all the containers to communicate with each other as needed.

I have a feeling some of these variables and configurations might still be a little confusing, so let’s take a moment to recap the important ones and how to set them up correctly in the future. Throughout the remainder of this handbook, I will include docker-compose service blocks that I’ve tested to work with this self-hosting stack, but there are likely other containers you will want to add to your stack, and you’ll only be able to do that if you understand how these environment variables work.

As I mentioned on the previous page, there are three critical environment variables: VIRTUAL_HOST, LETSENCRYPT_HOST, and LETSENCRYPT_EMAIL. Let me quote myself from that page.

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.

There are a few other environment variables that you may need to employ.

VIRTUAL_PORT

To get started, here’s what the nginx-proxy documentation says about VIRTUAL_PORT:

If your container exposes multiple ports, nginx-proxy will default to the service running on port 80. If you need to specify a different port, you can set a VIRTUAL_PORT env var to select a different one. If your container only exposes one port and it has a VIRTUAL_HOST env var set, that port will be selected.

By default, nginx-proxy will assume that your container will be listening for traffic on port 80. If the ports section in the docker-compose configuration reads something like - "667:80", then you don’t need to use VIRTUAL_PORT. In that case, the Docker containing will be listening to traffic on port 80, and nginx-proxy will take care of the rest.

But, what if the ports section was something like - "3000:5000? This means that the Docker container is listening for traffic on port 5000. You must use VIRTUAL_PORT in this case to force nginx-proxy to send traffic to the correct port, and not the 80 default.

If this doesn’t quite make sense yet, we’ll see this VIRTUAL_PORT variable in action when adding Gitea to our stack in the next step.

Example: Adding Gitea to your self-hosting stack using docker-compose

Your Portainer container should be working just fine, but you didn’t get all this way to have a Docker monitoring solution and no Docker containers! Let’s get started with a popular choice for self-hosting: an alternative to hosted (and often paid) Git hosting services like GitHub.

By adding Gitea to your self-hosting stack, you’ll be able to host your own version-controlled software, whether you keep it private or share it with the world!

Add a new DNS record

Gitea will require its own subdomain, just as Portainer does at docker.DOMAIN.TLD. Head back into your DNS configuration and create a new A record at the subdomain of your choosing. I tend to pick gitea, but it’s entirely up to you.

Add to the docker-compose.yml file

Let’s get right into it. Here’s the entire block to add to the services section of your docker-compose.yml file.

  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: unless-stopped
    environment:
      - VIRTUAL_HOST=gitea.DOMAIN.TLD
      - LETSENCRYPT_HOST=gitea.DOMAIN.TLD
      - LETSENCRYPT_EMAIL=EMAIL
      - VIRTUAL_PORT=3000
      - ROOT_URL=https://gitea.DOMAIN.TLD
      - DOMAIN=gitea.DOMAIN.TLD
      - PROTOCOL=http
      - USER_UID=1000
      - USER_GID=1000
    volumes:
      - ./gitea:/data
    ports:
      - "5000:3000"
      - "222:22"
    networks:
      - proxy-tier
      - default

At first glance, this configuration is far more complicated than Portainer. While that may be true to an extent, much of this should be familiar to you. The first three environment variables are expected, and you can see the VIRTUAL_PORT variable as well. We’re telling nginx-proxy that Gitea will be listening for traffic on port 3000, which you can see clarified down in the ports section as well: `- “5000:3000”.

The remainder of the environment variables—ROOT_URL, DOMAIN, PROTOCOL, USER_UID, and USER_GID—are Gitea-specific. Instead of informing nginx-proxy, these variables tell Gitea how it should run. Simply the other DOMAIN and TLD variables with your domain and TLD, and leave the rest as-is.

Run docker-compose up -d

Whenever you add to or otherwise change up your docker-compose.yml file, all you need to do to deploy these changes is run docker-compose up -d again. You should see output similar to the following:

$ docker-compose up -d
Pulling gitea (gitea/gitea:latest)...
latest: Pulling from gitea/gitea
911c6d0c7995: Pull complete
f36b1d28f2ac: Pull complete
d73c2cfa601e: Pull complete
744db10b7a3b: Pull complete
03cb1114429b: Pull complete
a28e30c2301a: Pull complete
Digest: sha256:464544cf5a7e8adf1430002a6b38076d5b5be2563363c3227144b2ae68b9a7e3
Status: Downloaded newer image for gitea/gitea:latest
Creating gitea ...
proxy is up-to-date
portainer is up-to-date
Creating gitea ... done

Hop over to your browser and navigate to the URL you specified via the DNS record and in your docker-compose.yml file. You’ll see a fully-encrypted, self-hosted Gitea installation waiting for you! You can create your user and get started right away.

Example: Adding FreshRSS to your self-hosting stack using docker-compose

Another widespread use of self-hosting is an RSS reader. I’ve covered this topic in the past, and have since figured out an effortless way to add FreshRSS to this stack.

As with the previous example, start by adding a new A record to your DNS. Then, add the following to your docker-compose.yml file:

  freshrss:
    image: linuxserver/freshrss
    container_name: freshrss
    restart: unless-stopped
    environment:
      - VIRTUAL_HOST=feed.DOMAIN.TLD
      - LETSENCRYPT_HOST=feed.DOMAIN.TLD
      - [email protected]
      - PGID=1001
      - PUID=1000
    volumes:
      - ./freshrss:/config
    ports:
      - "667:80"
    networks:
      - proxy-tier
      - default

Run docker-compose up -d again to refresh your self-hosting stack.

$ docker-compose up -d
Pulling freshrss (linuxserver/freshrss:)...
latest: Pulling from linuxserver/freshrss
94c34ef7d9ee: Pull complete
e169d46472b9: Pull complete
6f85777a9276: Pull complete
6359afc496ca: Pull complete
945860b71d75: Pull complete
db1f674ef8a6: Pull complete
6e560e43af06: Pull complete
Digest: sha256:7be62e52282273f672f28dec26979ac64f53a4d14b244c67875b2c7bb8010278
Status: Downloaded newer image for linuxserver/freshrss:latest
gitea is up-to-date
Creating freshrss ...
proxy is up-to-date
portainer is up-to-date
Creating freshrss ... done

Head over to the URL you specified, and a new FreshRSS installation will be waiting for your favorite feeds!

How does this actually work?

Now that you have seen some examples of how to add more Docker containers to this self-hosting stack, let’s take a moment to examine precisely how this system works together. Here’s the process, simplified into a handful of steps:

  1. You add a new service to docker-compose.yml file.
  2. You then run docker-compose up -d.
  3. The new Docker container(s) are downloaded and started.
  4. A service called docker-gen, running inside of nginx-proxy, recognizes that a new Docker container has started via the Docker sockets file (/var/run/docker.sock). Because this container has the VIRTUAL_HOST environment variable set, nginx-proxy knows it should initialize it as part of the reverse proxy network.
  5. The nginx-proxy container creates new reverse proxy Nginx rules to route traffic coming into the VPS to the appropriate place.
  6. The letsencrypt-nginx-proxy-companion also recognizes that a new Docker container has started via Docker sockets, and because it has the LETSENCRYPT_HOST environment variable set to a real domain, letsencrypt-nginx-proxy-companion knows to request a new SSL certificate from Let’s Encrypt.
  7. letsencrypt-nginx-proxy-companion and nginx-proxy coordinate to install the new SSL certificate and enable HTTPS traffic.
  8. letsencrypt-nginx-proxy-companion checks the existing SSL certificates every 3600 seconds (every 60 minutes) to verify if any are expiring soon. If so, it will register the SSL certificate again, ensuring traffic is always encrypted.

The underlying functionality is far, far more complicated than this numbered list suggests, but the beauty of this setup is that we don’t have to worry about it. The developers have abstracted much of the headache and complexity away from us, giving us full control of our self-hosting stack while making it relatively easy to use.

Next page: Self-hosting orchestration and administration

The fourth page of this self-hosting handbook is coming soon. Bookmark this guide and follow us on Twitter to get updates. Or, you can subscribe to our weekly blog newsletter, where I’ll let you know as soon as this guide expands.