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
- (Theoretical) manual setup
- Utilizing Traefik and PiHole
- Infrastructure As Code approach
- Summary
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!