profile picture

Home tech stack

November 02, 2021 - tech stack

I have tried UML, Vserver, openvz, lxc and now docker-compose. After using k8s at work, I tried canonical kubernetes to get some more experience with running it. At that time it was complicated to setup and it was using a lot of resources even when nothing was running. Maybe I didn't configure it properly but it felt overkill for what I wanted to do. I am not hosting software for thousands, millions or more users. The software I self-host is mainly for my wife and myself, with the exception of my blog.

So, now I use docker-compose for my home services and I like the setup. I thought other people might be interested in this setup.

For example, I use gitea to host my private repositories, drone CI for CI/CD, postfix/dovecot for email and I run an nginx for terminating TLS and routing traffic to my web services.

Simple example: Gitea

My Gitea setup is quite simple, so I think it's a good example to start with. Please note that the examples below are censored (e.g. IP ranges are replaced by Documentation ranges)

$ find .
.
./up.sh               # script to start service
./down.sh             # script to stop service
./docker-compose.yml  # config file describing service

up.sh:

#!/bin/sh

docker-compose up -d

down.sh:

#!/bin/sh

docker-compose down

Obviously these scripts contain very simple commands. Some of my other services need more commands to start/stop so I like having a consistent way of starting/stopping services 1.

The docker-compose.yaml holds the configuration. The only special things I do here is mount some persistent data and fixed IP addresses.

version: '3'
services:
  gitea:
    image: gitea/gitea:latest-rootless
    restart: always
    user: "1000"
    volumes:
      # Persistent data on the host
      - /app/gitea/data:/var/lib/gitea
      - /app/gitea/config:/etc/gitea
      # Get the same timezone info as the host
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    expose:
      # not really necessary, it is more
      # informative for myself
      - "3000:3000"
      - "22:22"
    networks:
      default:
        ipv4_address: "192.0.2.22"
        ipv6_address: "2001:db8::22"

networks:
  default:
    external:
      # I have created a docker network with
      # fixed IPv4 and IPv6 subnets
      name: dmz

If we are in the gitea directory, then we can use docker-compose ps to show only docker containers of this service:

$ docker-compose ps
    Name                   Command               State                       Ports
-------------------------------------------------------------------------------------------------------
gitea_gitea_1   /usr/local/bin/docker-entr ...   Up      0.0.0.0:22->22/tcp, 0.0.0.0:3000->3000/tcp

Or execute commands in one of the containers (only one container here):

$ docker-compose exec gitea ls
custom    db        indexers  queues
data      git       log       ssh

That is all for the gitea service. Of course gitea itself needs some configuration (stored on the host in /app/gitea/{config,data}), but that is independent of docker-compose. Both my docker-compose directories as well as the storage (/app/*) is backed up to a remote location.

Less simple example: Reverse proxy

At home I only have one public IPv4 address so I only have one entry point from the outside. Since I only have one address, I decided to have a reverse proxy to route requests to separate services. I do not need it for load balancing, since I only have one instance of each services. Since I have a central place where all traffic comes in, it is also easier to terminate TLS.

$ find .
.
./build.sh                 # a script to build service
./up.sh                    # a script to turn on service
./down.sh                  # a script to turn off service
./docker-compose.yml       # configuration of services
./nginx
./nginx/Dockerfile         # base image for nginx
./htpasswd.sh              # a script to update basic auth passwords
./cert-reload.sh           # a script to reload when certificates are updated

As with gitea, I have up/down scripts, but also a build script since for nginx I make my own image. The build.sh script is simple:

#!/bin/sh

docker-compose build

The up.sh script is more complex than for gitea, since it is checking the config before it (re)starts:

#!/bin/sh
set -e

# mount the same volumes as we would with docker compose
VOLUMES=$(awk '/- (\/app.*)/ { print "-v " $2 }' docker-compose.yml)
docker run $VOLUMES -it docker.example.com/rev-proxy:latest nginx -t

docker-compose up -d

The down.sh script is the same as for gitea.

For my rev-proxy service I build a custom docker image. In some cases a public image might not fit the requirements. However, while writing this blog post I realized that my custom docker image can be simplified and I probably do not need it. Anyway, for the sake of the example, I'll show the custom docker image.

This is my rev-proxy/Dockerfile:

FROM debian:bookworm-20210902-slim

RUN apt-get update && apt-get install -y nginx apache2-utils

CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

The docker-compose.yml is setup in a similar way is with gitea:

version: "3"
services:
  nginx:
    image: docker.example.com/rev-proxy
    build: ./nginx
    restart: always
    expose:
      - "80"
      - "443"
    networks:
      default:
        ipv4_address: "192.0.2.80"
        ipv6_address: "2001:db8::80"
    volumes:
      - /app/rev-proxy/nginx/log:/var/log/nginx
      - /app/rev-proxy/nginx/etc/conf.d:/etc/nginx/conf.d
      - /app/rev-proxy/nginx/etc/htpasswd:/etc/nginx/htpasswd
      - /app/rev-proxy/nginx/etc/sites:/etc/nginx/sites
      - /app/rev-proxy/nginx/etc/sites-enabled:/etc/nginx/sites-enabled
      - /app/rev-proxy/nginx/etc/sites-available:/etc/nginx/sites-available
      - /app/certs/auto-certs/certs:/certs
      - /app/static-websites:/static
      - /dev/log:/dev/log

networks:
  default:
    external:
      name: dmz

Now I can update my config at /app/rev-proxy/nginx/etc and run ./up.sh to test the config and restart:

$ ./up.sh
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
revproxy_nginx_1 is up-to-date

I also have a htpasswd.sh script:

#!/bin/sh

cd $(dirname $0)

if [ $# -lt 2 ]; then
	echo "Usage: $0 htpasswdfile username"
	docker-compose exec -T nginx ls -1 /etc/nginx/htpasswd/
	exit 1;
fi

FILE=$1
USER=$2


docker-compose exec -T nginx htpasswd /etc/nginx/htpasswd/$FILE $USER

This is to add/update my basic auth that I use for some services (a very low security threshold to keep bots and crawlers out).

Finally, I mentioned cert-reload.sh:

#!/bin/sh

cd $(dirname $0)

docker-compose exec -T nginx sh -c "nginx -t && nginx -s reload"

This is to reload nginx after one of my TLS certificates has changed. This certificate update process also runs via a docker-compose service of course. Any service that uses certificates has a cert-reload.sh script that is called by the update process.

Summary

These are two examples of services that I run with docker-compose. As long as docker starts at boot time, it will start my services. I do not need highly available services for my home stack, so docker-compose is good enough. It is low maintenance (I run updates, but can not remember the last time I had to fix something).

1

I have considered a Makefile as well (make up/make down), but I realized that if I have to do something with a for-loop or if-statement, it would be more annoying to write. I decided to keep it simple.