Welcome to the second part of the Wyse Decision series where we tackle the setup of the first HACK server. As we already have the platform done, we can start putting services on it.

Software overview

Ansible

If you would like to read a brief introduction to Ansible I highly recommend this part of the previous article.

Long story short - this is the tool that we will be using to automate the maintenance of the server and the setup of our services. In this article I will show you a couple of code snippets that you can use.

PiHole - DNS / DHCP

The Pi-hole® is a DNS sinkhole that protects your devices from unwanted content, without installing any client-side software.

The primary use of PiHole is to block unwanted traffic in your network. For example the pesky telemetry or ads. It can also serve as a private local DNS and DHCP server. If you start searching the web you will find many people utilizing it in their home environments to great benefit.

It does not help with YouTube ads unfortunately as they are served from the same domain as the videos are.

Official documentation: https://docs.pi-hole.net/

Traefik - Reverse proxy

Traefik is an open-source Application Proxy and the core of the Traefik Hub Runtime Platform.

If you start with Traefik for service discovery and routing, you can seamlessly add API management, API gateway, AI gateway, and API mocking capabilities as needed.

I am using it as a reverse proxy. What is a reverse proxy? It initiates the connections from itself to some services on your behalf. It can add SSL certificates along the way and simplify the address resolution to your local services in your home environment. Just as an example - running an AI interface locally. You can serve it on an IP like 192.168.1.42:8080 or use the reverse proxy to give you a nice address like ai.example.com with a proper HTTPS connection and SSL certificate.

Official documentation: https://doc.traefik.io/traefik/

(Theoretical) manual setup

Services

We have the platform now. It is time to tackle the services. Both PiHole and Traefik will be running as docker containers which of course you could spin up using docker run command. We want to have them more defined though. To achieve this we will be using Docker Compose files. These are prewritten definitions of how we would like to run the container(s). Plus we will reuse them in our IAC setup. :)

Docker Compose

Docker Compose is an integral part of the Docker stack that can be used to define the “run” part of docker containers. The tool should be installed alongside Docker on the server. The file composition will look more or less like this:

services:                                       # header of services section
  service-1:                                    # first service name
    image: dhi.io/docker:29-cli-dev             # image to download from docker hub or other container registry
    volumes:                                    # volumes section - defining persistent storage
      - type: bind                              # type of the storage
        source: ./proxy/nginx.conf              # source - in this example local directory on the server
        target: /etc/nginx/conf.d/default.conf  # target - where it will be mounted on the container
        read_only: true                         # prevent writing to the directory by the container
    ports:                                      # ports to map
      - 80:80                                   # port 80 of the container will be bound to port 80 on host 
    depends_on:                                 # dependency - it will only start after successful start of other service
      - service-2

  service-2:                                    # second service name
    build:                                      # build section instead of image - the code will be built on the server before start
      context: service-2                        # context for the build
      target: builder                           # tool to use

For further information here is a nice article from Docker: https://docs.docker.com/reference/compose-file/

PiHole

To set up PiHole we will need two files:

  • compose.yml
services:
  pihole:                                     
    container_name: pihole
    hostname: pihole
    image: docker.io/pihole/pihole:2026.04
    restart: unless-stopped                                                   # restart the service if it dies
    network_mode: host                                                        # while using PiHole as DHCP running it in host network mode is simplest and sufficiently safe for homelab use
    env_file: .env                                                            # point it to correct .env file - it will be read at startup and will populate environment variables for the container
    volumes:                                                                  # persistent storage using docker volumes
      - pihole:/etc/pihole
      - dnsmasq:/etc/dnsmasq.d
    environment:
      TZ: Europe/Warsaw                                                       
      TEMPERATUREUNIT: c                                                      
      FTLCONF_dns_upstreams: '1.1.1.1;1.0.0.1;208.67.222.222;208.67.220.220'  # my suggestion only - Cloudflare and OpenDNS for network DNS resolution 
      FTLCONF_webserver_port: '8080o,[::]:8080o,8443os,[::]:8443os'           # ports for web interface to use - we need to specify them here as we are using host network mode. In other case those could go to "ports" section of compose file 
      DNSMASQ_LISTENING: all
    cap_add:
      - NET_ADMIN                                                             # required if you are using Pi-hole as your DHCP server, else not needed

volumes:
  pihole:
  dnsmasq:
  • .env
FTLCONF_webserver_api_password=<your_password_to_pihole>

Once you have those files move them to your server. The most common practice is to keep them in subdirectories of /opt. On my side it looks like this:

/opt/pihole/
├── compose.yml
└── .env

Once we have those on the server let’s change the ownership of the files to our admin user by running the command:

sudo chown -R <admin_user>:<admin_user> /opt/pihole # sudo chown albert:albert /opt/pihole

Now you should be able to run it via:

cd /opt/pihole && docker compose up -d

The -d flag is to run it in detached mode. That means it will run in the background. Docker will download the image and run the service for you based on the compose.yml file. Feel free to check if it is running with:

docker compose ls

And you should see something like this as an output:

NAME                STATUS              CONFIG FILES
pihole              running(1)          /opt/pihole/compose.yml

Also you should be able to reach the PiHole web interface on your server’s IP and port 8080. For me it will be 192.168.1.3:8080/admin/.

On the login panel probably you will see DNS Server failure detected, log in to see Pi-hole diagnosis messages. That’s because Ubuntu Server has its own internal DNS stub resolver. We need to alter that a bit for PiHole to run properly.

Edit the file /etc/systemd/resolved.conf. Find #DNSStubListener=yes and change it to DNSStubListener=no using your favourite text editor.

After that, delete /etc/resolv.conf and create a symlink for it pointing to /run/systemd/resolve/resolv.conf.

sudo rm /etc/resolv.conf
sudo ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf

Lastly restart systemd-resolved.

sudo systemctl restart systemd-resolved

Don’t forget to open the firewall for the PiHole to function properly. Ports of concern are:

  • 53/udp (DNS)
  • 53/tcp (DNS)
  • 67/udp (DHCP)
  • 8080/tcp (HTTP - web interface)
  • 8443/tcp (HTTPS - web interface)

Go one by one and open them via:

sudo ufw allow <port>/<protocol> # example: sudo ufw allow 53/tcp 

With that tackled, you can set the server’s IP address as the DNS server on your router or proceed with me to set up DHCP on PiHole. To do that go to your router’s settings and disable the DHCP server entirely. Once that is tackled go to /admin/settings/dhcp on PiHole’s web interface. Put in the address range and gateway address (for me it will be 192.168.1.128 - 192.168.1.254 and 192.168.1.1 respectively) and enable the DHCP server.

That’s it!

Traefik

Traefik will require us to do a little more than before. We will need:

  • compose.yml
services:
  traefik:
    image: traefik:v3.6
    container_name: traefik
    hostname: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    env_file: .env
    dns:
      - 192.168.1.3 # PiHole's server IP
    extra_hosts:
      - "host.docker.internal:host-gateway" # additional gateway to containers on the same server
    networks: 
      - proxy # custom network of type "bridge" - if we ever put more containers on the same server and would like to use labels for routing they will need to be on the same network
    ports:
      - 80:80/tcp     # routing HTTP port
      - 443:443/tcp   # routing HTTPS port
      - 9080:8080/tcp # web interface port - 8080 is taken by PiHole
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro  # direct access to server's docker socket
      - /etc/localtime:/etc/localtime:ro              # local time
      - ./traefik.yml:/traefik.yml:ro                 # traefik configuration path - file will reside on the server but we want the container to have read only (ro) access
      - ./config:/config:ro                           # routing configurations - same as above
      - ./log:/var/log/traefik:rw                     # log folder
      - traefik_certs:/certs                          # docker volume to store the SSL certificates
    environment:
      - TZ=Europe/Warsaw
    labels:                                           # traefik labels - I will not go though all of them but if you want to understand please take a look here: https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/docker/
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.entrypoints=web"
      - "traefik.http.routers.traefik.rule=Host(`${DASHBOARD_URL}`)"
      - "traefik.http.middlewares.traefik-auth.basicauth.users=${DASHBOARD_CREDENTIALS}"
      - "traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.middlewares.sslheader.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.traefik.middlewares=traefik-https-redirect"
      - "traefik.http.routers.traefik-secure.entrypoints=websecure"
      - "traefik.http.routers.traefik-secure.rule=Host(`${DASHBOARD_URL}`)"
      - "traefik.http.routers.traefik-secure.middlewares=traefik-auth"
      - "traefik.http.routers.traefik-secure.tls=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.traefik-secure.tls.domains[0].main=${PUBLIC_DOMAIN}"
      - "traefik.http.routers.traefik-secure.tls.domains[0].sans=*.${PUBLIC_DOMAIN}"
      - "traefik.http.routers.traefik-secure.tls.domains[1].main=${LOCAL_DOMAIN}"
      - "traefik.http.routers.traefik-secure.tls.domains[1].sans=*.${LOCAL_DOMAIN}"
      - "traefik.http.routers.traefik-secure.service=api@internal"

volumes:  # docker volume definition
  traefik_certs:
    name: traefik_certs

networks: # network definition - this one will be created if not present already
  proxy:
    name: proxy

There is much going on. From the top priorities I would recommend deciding on buying a domain to use Cloudflare (free tier) or using a DuckDNS for a totally free alternative. The second to look at is all the environment variables we are using in labels. Those will reside in the .env file.

  • .env
# Cloudflare
CF_API_TOKEN=<token>

# Domains
PUBLIC_DOMAIN=<your_public_domain>
LOCAL_DOMAIN=<your_local_domain>          # this could be a subdomain of the public one like home.public.com or something entirely different but needs to point to the traefik's local IP

# Traefik
DASHBOARD_URL=traefik.<your_local_domain> # web interface URL
DASHBOARD_CREDENTIALS=<admin_user>:<encrypted_password>

To get the credentials log in to any linux machine on your hand - could be the server that you have already set up.

sudo apt install apache2-utils -y                                             # this will install necessary package
echo $(htpasswd -nbB <admin_user> "<your_password>") | sed -e s/\\$/\\$\\$/g  # this will give you encrypted string to put in .env
  • traefik.yml
global:
  checkNewVersion: true

log:
  level: INFO

api:
  dashboard: true
  insecure: true
  debug: false

entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: :443

serversTransport:
  insecureSkipVerify: true

providers:
  file:
    directory: "/config"
    watch: true
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false

certificatesResolvers: # this part will need to be altered if you go the DuckDNS route. Please see Traefik documentation.
  cloudflare:
    acme:
      email: <your_real_email>
      storage: /certs/acme.json
      caServer: https://acme-v02.api.letsencrypt.org/directory # prod (default)
      # caServer: https://acme-staging-v02.api.letsencrypt.org/directory # staging - use this one until you are sure the service is running correctly :)
      dnsChallenge:
        provider: cloudflare
        propagation:
          delayBeforeChecks: 10s
  • configs for reverse proxy routing

We will create a subfolder config in /opt/traefik and add there first two files - a middleware config and a route to PiHole.

# /opt/traefik/config/middlewares.yml
http:
  middlewares:
    https-redirect-scheme:
      redirectScheme:
        scheme: https
        permanent: true

    default-headers:
      headers:
        frameDeny: true
        browserXssFilter: true
        contentTypeNosniff: true
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 15552000
        customFrameOptionsValue: SAMEORIGIN
        customRequestHeaders:
          X-Forwarded-Proto: https
  
    default-whitelist:
      ipAllowList:
        sourceRange:
        - "10.0.0.0/8"
        - "192.168.0.0/16"
        - "172.16.0.0/12"
  
    secured:
      chain:
        middlewares:
        - default-whitelist
        - default-headers
# /opt/traefik/config/pihole.yml
http:
  routers:
    pihole:
      entryPoints:
        - websecure
      rule: "Host(`pihole.<your_local_domain>`)"
      middlewares:
        - default-headers
        - https-redirect-scheme
      tls: {}
      service: pihole
    
  services:
    pihole:
      loadBalancer:
        servers:
          - url: "http://192.168.1.3:8080"

And it all should look like this on the server:

/opt/traefik/
├── compose.yml
├── config
│   ├── middlewares.yml
│   └── pihole.yml
├── .env
└── traefik.yml

Now change the permissions of the folder:

sudo chown -R <admin_user>:<admin_user> /opt/traefik # sudo chown -R albert:albert /opt/traefik 

Open the ports like before:

  • 80/tcp
  • 443/tcp
  • 9080/tcp

…and with those in place let’s fire it up!

cd /opt/traefik && docker compose up -d
NAME                STATUS              CONFIG FILES
pihole              running(1)          /opt/pihole/compose.yml
traefik             running(1)          /opt/traefik/compose.yml

If all goes well you should have two services running and the first building blocks to set up more services.

Utilizing Traefik and PiHole

To unleash the full power of this combination go to your PiHole’s /admin/settings/dnsrecords URL. In there you will have two things to do. First set Local DNS records or record. Put your local domain in Domain column and the server’s IP address in IP column. This will resolve all requests to this domain to this IP address. Now to utilize the reverse proxy add two records in Local CNAME records. In Domain put whatever URL you provided for Traefik to resolve. Most probably it would be both traefik.local and pihole.local. For each of them put Target as the same Domain you gave to Local DNS records. It will tell the DNS to look for those addresses under the domain address leading to the Traefik instance - Brilliant!

As a nice addition you should be able to visit your PiHole dashboard under pihole.whatever_your_local_domain.com/admin and it will have an SSL certificate and an HTTPS connection provided by Traefik!

Infrastructure As Code approach

Now comes the more fun part - AUTOMATION. Of course you can see all the things I do in the HACK repository under the proper branch.

Update systems

Do you remember our manual updates? Now is the second best time to automate them a bit. The first best time was yesterday.

First we would need to add a subcategory of systems in our hosts.yml file to point a set of Debian-based systems only (Ubuntu Server is Debian-based).

# ./ansible/inventory/hosts.yml

---
all:
  children:
    metal:
      hosts:
        system-1:
          inventory_host: hostname-1
          ansible_host: ip-1

    debian: # this part is new
      hosts:
        system-1:

Now a simple playbook with a handler.

# ./ansible/playbooks/update-debian-systems.yml

---
- name: Update all Debian based systems
  hosts: debian # point it to only debian-based systems
  become: true
  vars:
    reboot_timeout: 300

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Upgrade all packages
      ansible.builtin.apt:
        upgrade: full
      register: upgrade_result
      notify: Show upgraded packages

    - name: Remove unused dependencies
      ansible.builtin.apt:
        autoremove: true

    - name: Clean apt cache
      ansible.builtin.apt:
        autoclean: true

    - name: Check if reboot is required
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_required

    - name: Reboot if required
      ansible.builtin.reboot:
        reboot_timeout: "{{ reboot_timeout }}"
        msg: "Reboot triggered by Ansible after system update"
      when: reboot_required.stat.exists

    - name: Verify host is responsive after reboot
      ansible.builtin.ping:
      when: reboot_required.stat.exists

  handlers: # those will run only when changes were done
    - name: Show upgraded packages
      ansible.builtin.debug:
        msg: "{{ upgrade_result.stdout_lines }}"

To spice things up we can add an entry in Makefile to ease our lives in the future a bit.

# ./Makefile

# Updates
## debian-based
update-debians: 
	ansible-playbook ansible/playbooks/update-debian-systems.yml

Simple, as promised :)

Setup services

Good news that we already have a good portion of the files needed. You can move all of them, apart from .env files, to a directory in your repository ./ansible/files/.

ansible/files/
├── pihole
│   └── compose.yml
└── traefik
    ├── compose.yml
    ├── config
    │   ├── middlewares.yml
    │   └── pihole.yml
    └── traefik.yml

Ansible Vault

A place to store our environment variables would be nice. Let’s create an encrypted Ansible vault. From the directory of your project run a command:

ansible-vault create <path> # ansible-vault create ./ansible/vault/secrets.yml

It will prompt you for a password. Make it secure and write it down. Mine is mypass in case you would like to take a peek into the example vault :)

You can also create a file in your local user’s directory named like .vault_pass and put the password there to not retype it every time. It will stay on your local PC and DOES NOT belong in the repository.

After you provide the password it’s time to fill the vault with data. This will be fed into our .env files later in the process. These will would suffice for our needs:

---
# Common
public_domain: "public.com"
local_domain: "local"

# PiHole
pihole_admin_password: "<password>"

# Traefik
traefik_dashboard_credentials: "<admin_user>:<password>"
cf_dns_api_token: "<token>"

docker_deploy role

We want to automate the Docker Compose deployments a bit and we can do that via creating a reusable role that you can point in any service’s direction to deploy it. Create a docker_deploy role directory in your roles directory and let’s start writing!

Default values go in defaults. We will be providing overrides at playbook run for anything that is not standard.

# ./ansible/roles/docker_deploy/defaults/main.yml
---
# Required
service_name: ~
service_dir: "/opt/{{ service_name }}"
service_compose_file: ~

# Optional
service_user: "{{ admin_user }}"
service_env: {}
service_extra_files: []
service_extra_dirs: []

A little handler for service restart.

# ./ansible/roles/docker_deploy/handlers/main.yml
---
- name: Restart service
  community.docker.docker_compose_v2:
    project_src: "{{ service_dir }}"
    state: restarted

And a real tasks set:

# ./ansible/roles/docker_deploy/tasks/main.yml
---
- name: Create service directory
  ansible.builtin.file:
    path: "{{ service_dir }}"
    state: directory
    owner: "{{ service_user }}"
    group: "{{ service_user }}"
    mode: "0755"

- name: Create extra directories # for traefik configs for example
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
    owner: "{{ service_user }}"
    group: "{{ service_user }}"
    mode: "0755"
  loop: "{{ service_extra_dirs }}"
  when: service_extra_dirs | length > 0

- name: Deploy compose.yml
  ansible.builtin.copy:
    src: "{{ service_compose_file }}"
    dest: "{{ service_dir }}/compose.yml"
    owner: "{{ service_user }}"
    mode: "0644"
  notify: Restart service

- name: Deploy .env file # generate .env from service variables - you will see what we did there in a bit
  ansible.builtin.copy:
    content: |
      {% for key, value in service_env.items() %}
      {{ key }}={{ value }}
      {% endfor %}
    dest: "{{ service_dir }}/.env"
    owner: "{{ service_user }}"
    mode: "0600"
  when: service_env | length > 0
  notify: Restart service

- name: Deploy extra files
  ansible.builtin.copy:
    src: "{{ item.src }}"
    dest: "{{ service_dir }}/{{ item.dest }}"
    owner: "{{ service_user }}"
    mode: "{{ item.mode | default('0644') }}"
  loop: "{{ service_extra_files }}"
  when: service_extra_files | length > 0
  notify: Restart service

- name: Start service
  community.docker.docker_compose_v2:
    project_src: "{{ service_dir }}"
    state: present

setup-system-1.yml playbook

To tie everything up we will edit a bit our setup playbook. We had a little dummy task here. The first part stays - we still need common packages and Docker installed. What changes are two new sections - one for each service we want to deploy.

# ./ansible/playbooks/setup-system-1.yml
---
- name: Setup system-1 node
  hosts: system-1
  become: true
  vars_files: # we will use the secrets.yml now
    - "{{ playbook_dir }}/../vault/secrets.yml"
  roles:
    - common
    - docker

- name: Deploy PiHole
  hosts: system-1
  become: true
  vars_files:
    - "{{ playbook_dir }}/../vault/secrets.yml"
  roles:
    - role: docker_deploy # our new role :)
      vars:
        service_name: pihole-1
        service_dir: /opt/pihole
        service_compose_file: "{{ playbook_dir }}/../files/pihole/compose.yml"
        service_env: # .env file variables - like we did with manual setup but this time safely stored in Ansible Vault and moved to .env during runtime
          FTLCONF_webserver_api_password: "{{ pihole_admin_password }}"

  tasks:
    # Needed for PiHole to function properly as DNS
    - name: Disable DNSStubListener in systemd-resolved
      ansible.builtin.replace:
        path: /etc/systemd/resolved.conf
        regexp: "^(#DNSStubListener=yes)+$"
        replace: "DNSStubListener=no"
      notify: Restart systemd-resolved

    - name: Remove /etc/resolv.conf
      ansible.builtin.file:
        path: /etc/resolv.conf
        state: absent
      notify: Restart systemd-resolved

    - name: Create a resolv.conf symlink for Netplan
      ansible.builtin.file:
        src: /run/systemd/resolve/resolv.conf
        dest: /etc/resolv.conf
        state: link
      notify: Restart systemd-resolved

    - name: Update firewall rules
      community.general.ufw:
        rule: allow
        port: "{{ item.value.port }}"
        proto: "{{ item.value.protocol }}"
      loop: "{{ ports_to_open | dict2items }}"
      vars:
        ports_to_open:
          pihole_dns_udp:
            port: 53
            protocol: udp
          pihole_dns_tcp:
            port: 53
            protocol: tcp
          pihole_dhcp_udp:
            port: 67
            protocol: udp
          pihole_http_tcp:
            port: 8080
            protocol: tcp
          pihole_https_tcp:
            port: 8443
            protocol: tcp

  handlers:
    - name: Restart systemd-resolved
      ansible.builtin.systemd:
        name: systemd-resolved
        enabled: true
        state: restarted

- name: Deploy Traefik
  hosts: system-1
  become: true
  vars_files:
    - "{{ playbook_dir }}/../vault/secrets.yml"
  roles:
    - role: docker_deploy
      vars:
        service_name: traefik-1
        service_dir: /opt/traefik
        service_compose_file: "{{ playbook_dir }}/../files/traefik/compose.yml"
        service_extra_dirs:
          - "{{ service_dir }}/config"
        service_extra_files: # we needed to move those manually - right? Not anymore
          - src: "{{ playbook_dir }}/../files/traefik/traefik.yml"
            dest: "traefik.yml"
          - src: "{{ playbook_dir }}/../files/traefik/config"
            dest: "."
        service_env: # .env file variables - like we did with manual setup but this time safely stored in Ansible Vault and moved to .env during runtime
          CF_API_TOKEN: "{{ cf_dns_api_token }}"
          PUBLIC_DOMAIN: "{{ public_domain }}"
          LOCAL_DOMAIN: "{{ local_domain }}"
          DASHBOARD_URL: "traefik.{{ local_domain }}"
          DASHBOARD_CREDENTIALS: "{{ traefik_dashboard_credentials }}"

  tasks:
    - name: Update firewall rules
      community.general.ufw:
        rule: allow
        port: "{{ item.value.port }}"
        proto: "{{ item.value.protocol }}"
      loop: "{{ ports_to_open | dict2items }}"
      vars:
        ports_to_open:
          traefik_http_tcp:
            port: 80
            protocol: tcp
          traefik_https_tcp:
            port: 443
            protocol: tcp
          traefik_dashboard_tcp:
            port: 9080
            protocol: tcp

Last thing - Makefile. It stays the same. We are reusing the playbook and providing everything needed inside.

Now comes the historical moment - run it!

make setup-system-1

After this runs, you should have fully functional services on your server :)

Manual configuration steps in PiHole like enabling DHCP and so on stay the same as in the manual part.

Summary

We’re at the end of part two of our two part series. You now have a fully functional DNS / DHCP server with a reverse proxy that provides SSL certificates to any connection made via it. Good job!

Take your time to review what we have done. Maybe tweak something, break it and fix it. Add a service. Add a server to the mix. The world is your oyster.

And most importantly - HAVE FUN!