Home Automation, Part 2: Setting Up the Home Lab

Second article in the home automation series — a Debian server running Docker, Home Assistant and ESPHome.

Architecture diagram: A02YYUW ultrasonic sensor feeding an ESP32 over UART, which talks WiFi to a home lab server running Home Assistant and the ESPHome dashboard

Where We Left Off

In the previous article, I flashed an ESP32 to connect to Wi-Fi and to an ultrasonic sensor, and read water-level data from it. But where would that data go? I needed a server at home for the ESP32 to publish to — a home lab. In this article, I’ll walk through setting one up with the basic components needed.

Why a Home Lab?

There were multiple reasons — cost of operation and security being the primary drivers. I don’t want to pay for cloud compute, at least in these early stages of my exploration. At the same time, I don’t want to expose automation hooks to my home devices over the cloud. And I can customize the setup as per my needs. Keeping all this in mind, I decided to go for a home lab. I found an old laptop I’d bought back in 2010 that had been sitting unused. It has an Intel Core i5 with 8 gigs of memory. Should be sufficient!

The Plan

The idea is to turn the laptop into a Linux box: switch it on, plug it in, and tuck it out of the way. The Linux installation had to be minimal — I didn’t want to weigh the laptop down with anything unnecessary. No user interface, no unnecessary packages. I’ll call the laptop the server from now on.

Here’s what the server needed to run:

Component Role Runs as
Debian server Always-on host Bare metal, headless
Docker + Compose Runs every service in the stack systemd service
Home Assistant Dashboards, automations Container
ESPHome dashboard Compile and flash ESP32 firmware Container

Home Assistant is an open-source home automation platform that got recommended to me by a colleague. It is the brain of the setup. It discovers devices on the local network, shows their current state and history on dashboards, and lets you build automations on top of them, such as sending a notification when the tank level drops too low. Devices flashed with ESPHome show up in it automatically, which is very helpful.

The ESPHome dashboard is a web UI for managing ESPHome devices. It is what we used from the command line in Part 1, now running on the server. From the browser you can edit a device’s YAML configuration, compile the firmware, flash it over USB the first time and over Wi-Fi after that, and watch live logs from the device. Although, at least so far, I’ve mostly used the command line to reflash the ESP32 rather than the dashboard itself.

Architecture

This is how the setup is going to play out.

flowchart LR
    subgraph Tank["Overhead Water Tank"]
        Sensor["A02YYUW\nUltrasonic Sensor"]
    end

    subgraph Device["ESP32 Devkit"]
        FW["ESPHome firmware"]
    end

    subgraph Server["Home Lab Server · Debian + Docker"]
        HA["Home Assistant\n:8123"]
        ESPD["ESPHome Dashboard\n:6052"]
    end

    Router["WiFi Router"]
    Phone["Phone / Browser"]

    Phone -->|"dashboard"| HA
    Sensor -->|"UART"| FW
    FW -->|"WiFi"| Router
    Router --> Server
    FW -.->|"native API"| HA
    ESPD -.->|"OTA flash"| FW

The Server

I started by installing a Linux distribution on the laptop.

Installing Debian

I erased the entire disk as I was not using the laptop for anything else. The ISO for installing Debian over the network can be found here. I made a minimal installation.

Terminal showing a successful SSH login to the homelab Debian server Once installed, I SSH'd into the server to confirm I could connect to it from my development machine.

One small tweak is needed to make sure the laptop does not go to sleep or power off when the lid is closed. This can be done by modifying the following settings in /etc/systemd/logind.conf:

HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore

Assigning a Static IP

Router app's Device Info screen showing the homelab device reserved at 192.168.68.200, with the MAC address blurred This is important. Generally, devices connected to a Wi-Fi router through DHCP get an IP address only for a certain lease period. Once the lease expires, the IP address changes. I can't afford that, as I need multiple devices connecting to the tools installed in the home lab. Most modern routers allow a static IP address to be assigned to a device based on its MAC address. It's better to do this from the router's end rather than trying to assign a static IP address within the laptop itself. With this, I now have a Linux server running at a static IP address. I can leave the laptop switched on and out of the way while I install the rest remotely.

Installing Docker

I wrapped the Docker installation in a small script, install-docker.sh, so I could repeat it on any fresh Linux box. I made it idempotent, so it is safe to re-run. I copied the bootstrap script to the server and ran it from my development machine:

scp scripts/install-docker.sh <user>@<server-ip>:~/
ssh <user>@<server-ip> 'bash ~/install-docker.sh'

Here is what it does, step by step.

Setup. Fail on the first error, define two small logging helpers, and work out which user should get Docker access:

#!/usr/bin/env bash
set -euo pipefail

log()  { printf '\033[1;32m==>\033[0m %s\n' "$1"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$1"; }

if [[ $EUID -eq 0 ]]; then
  warn "Running as root. That's fine for install, but the docker group step"
  warn "below is for a regular user — re-run as that user if this isn't it."
fi

TARGET_USER="${SUDO_USER:-$USER}"

Step 1: Install Docker. Skipped if it is already there. Otherwise Docker’s official convenience script detects the distro and installs the engine and the Compose plugin:

if command -v docker &>/dev/null; then
  log "Docker already installed ($(docker --version)) — skipping install."
else
  log "Installing Docker via get.docker.com ..."
  curl -fsSL https://get.docker.com | sh
fi

Step 2: Run Docker without sudo. This is why a log out and back in (or newgrp docker) is needed afterwards, as group membership is only picked up by new sessions:

if id -nG "$TARGET_USER" | grep -qw docker; then
  log "$TARGET_USER is already in the docker group."
else
  log "Adding $TARGET_USER to the docker group ..."
  sudo usermod -aG docker "$TARGET_USER"
  warn "Log out/in (or run 'newgrp docker') for this to take effect."
fi

Step 3: Start Docker on boot.

log "Enabling and starting the docker service ..."
sudo systemctl enable --now docker

Step 4: Verify. Print the versions and warn if the Compose plugin is missing:

log "Docker version: $(docker --version)"
if docker compose version &>/dev/null; then
  log "Compose plugin: $(docker compose version)"
else
  warn "docker compose plugin not found — get.docker.com should have installed"
  warn "it. Check manually: sudo apt install docker-compose-plugin (Debian/Ubuntu)"
fi

After logging back in, the same check by hand:

docker --version
docker compose version

The Docker Compose Stack

Next up was deploying the actual core components of the setup — Home Assistant and ESPHome — using Docker Compose. My docker-compose.yml looked something like this. It has the necessary volume mounts, and most importantly, network_mode: host, so the containers share the host’s network directly instead of Docker’s isolated bridge network — needed for mDNS discovery on the LAN.

services:
  homeassistant:
    image: ghcr.io/home-assistant/home-assistant:stable
    container_name: homeassistant
    restart: unless-stopped
    privileged: true
    network_mode: host   # simplest for mDNS/device discovery on the LAN
    volumes:
      - ./homeassistant/config:/config
      - /etc/localtime:/etc/localtime:ro

  esphome:
    image: ghcr.io/esphome/esphome
    container_name: esphome
    restart: unless-stopped
    network_mode: host   # needed for OTA discovery of devices on the LAN
    volumes:
      - ./esphome:/config
      - /etc/localtime:/etc/localtime:ro

Once the stack is up and running, these two services are reachable at their default ports:

Service Network mode Port
Home Assistant host 8123
ESPHome dashboard host 6052

Secrets Management

One important part is how secrets are managed. If you refer back to the previous article, you would see placeholders for the Wi-Fi SSID and password. I was going to check this project into GitHub with a possibility of making it public in the future, and I didn’t want to check in my passwords (not a good practice either way). ESPHome supports maintaining a separate secrets.yaml and interpolating its values into the main config, so I leveraged that.

Here’s what a typical secrets.yaml looks like for our use case so far:

wifi_ssid: "your-wifi-name"
wifi_password: "your-wifi-password"

ap_fallback_password: "changeme123"

I added a .gitignore to keep credentials and runtime state out of the repo:

# Secrets — never commit
esphome/secrets.yaml

# Runtime state / generated data
homeassistant/config/*

# ESPHome build cache
esphome/.esphome/

Bringing the Stack Up

It’s now finally time to bring up everything. Similar to how the Docker installation was wrapped into an idempotent script, I created one for setting up the rest of the stack as well.

cd home-automation
bash scripts/install-stack.sh

Here is what it does, step by step.

Setup. Same strict mode and helpers as before, plus a die helper that prints an error and exits. The script also moves into the repo root, regardless of where it is called from:

#!/usr/bin/env bash
set -euo pipefail

log()  { printf '\033[1;32m==>\033[0m %s\n' "$1"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$1"; }
die()  { printf '\033[1;31mERROR:\033[0m %s\n' "$1" >&2; exit 1; }

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_DIR"

[[ -f docker-compose.yml ]] || die "docker-compose.yml not found in $REPO_DIR — run this from inside the repo."

Step 1: Sanity checks. Docker must be installed and usable without sudo. This catches a missed log out/in after the previous script:

command -v docker &>/dev/null || die "docker not found. Run scripts/install-docker.sh first."
if ! docker info &>/dev/null; then
  die "docker is installed but not usable by $USER. Did you log out/in after install-docker.sh added you to the docker group? (or run: newgrp docker)"
fi
docker compose version &>/dev/null || die "docker compose plugin not found. Re-run scripts/install-docker.sh."

Step 2: Secrets. On the first run, secrets.yaml is created from the example and the script warns to fill in real values. On later runs the file is left alone. The script does not abort here, so it needs a re-run once the values are in:

if [[ ! -f esphome/secrets.yaml ]]; then
  cp esphome/secrets.yaml.example esphome/secrets.yaml
  warn "Created esphome/secrets.yaml from the example — it has placeholder"
  warn "values. Edit it now with your real Wi-Fi credentials:"
  warn "    \${EDITOR:-nano} $REPO_DIR/esphome/secrets.yaml"
  warn "Re-run this script after editing."
else
  log "esphome/secrets.yaml already present — leaving it as-is."
fi

Step 3: Bring the stack up.

log "Pulling images ..."
docker compose pull

log "Starting the stack (docker compose up -d) ..."
docker compose up -d

Step 4: Install the systemd unit so the stack survives a reboot. I cover this in detail in Surviving a Reboot below.

UNIT_PATH=/etc/systemd/system/home-automation.service
log "Installing systemd unit at $UNIT_PATH ..."
sed "s|__REPO_DIR__|$REPO_DIR|" "$SCRIPT_DIR/home-automation.service.template" \
  | sudo tee "$UNIT_PATH" >/dev/null

sudo systemctl daemon-reload
sudo systemctl enable home-automation.service
log "home-automation.service enabled — the stack will now start automatically on boot."

Step 5: Report status and print the URLs.

log "Current container status:"
docker compose ps

cat <<EOF

Done. Next:
  - Home Assistant:   http://$(hostname -I | awk '{print $1}'):8123
  - ESPHome dashboard: http://$(hostname -I | awk '{print $1}'):6052
  - Check the stack survives a reboot with: sudo reboot   (then re-ssh and run 'docker compose ps')
EOF

Surviving a Reboot

[Unit]
Description=home-automation docker compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=__REPO_DIR__
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0

[Install]
WantedBy=multi-user.target

In step 4 above, install-stack.sh uses sed to substitute __REPO_DIR__ with the real clone path and installs the result as /etc/systemd/system/home-automation.service.

sequenceDiagram
    participant Boot as Server boot
    participant D as docker.service
    participant S as home-automation.service
    participant C as Containers

    Boot->>D: start
    Boot->>S: start (after docker + network-online)
    S->>C: docker compose up -d
    C-->>S: homeassistant, esphome running
    Note over S,C: Idempotent — safe even if already up

Verifying

docker compose ps should show you two containers up and running.

CONTAINER ID   IMAGE                                          COMMAND
eab426086c00   ghcr.io/home-assistant/home-assistant:stable   "/init"
e4c6febd2e22   ghcr.io/esphome/esphome                        "/entrypoint.sh dash…"

Then I rebooted the server to confirm the stack comes back on its own:

sudo reboot
docker compose ps

Configuring the Services

Home Assistant Onboarding

Home Assistant's Welcome screen with a Create my smart home button

Home Assistant takes you through an onboarding wizard the first time the application is launched. The very first screen is this Welcome page, offering to either set up a fresh instance or restore one from a backup.

I picked Create my smart home and went through the rest of the wizard — creating the first admin account and setting a name and location for the installation — before landing on the empty dashboard.

Moving the ESP32 onto the Home Lab

This is how the entire setup works now. I flashed the ESP32 over a MicroUSB cable the first time; from the second flash onwards, with OTA reflashing enabled, I could reflash it directly through ESPHome.

sequenceDiagram
    actor Me
    participant Dash as ESPHome Dashboard
    participant ESP as ESP32
    participant HA as Home Assistant

    Me->>Dash: Edit overhead-tank-monitor.yaml
    Dash->>Dash: Compile firmware
    Dash->>ESP: Flash (USB first time, OTA after)
    ESP->>ESP: Boot, join WiFi
    ESP-->>HA: Announce over native API
    HA-->>Me: New device discovered

The configuration changes from Part 1 were small. The device is unchanged, but it now has a server to talk to:

api: {}          # native Home Assistant integration

ota:
  - platform: esphome   # OTA reflashing after the first USB flash
lambda: |-
  // Full = sensor ~3 in (76 mm) above the water surface; water depth is
  // 56 in (1422 mm), so empty = 59 in (1499 mm).
  const float SENSOR_TO_EMPTY_MM = 1499.0;
  const float SENSOR_TO_FULL_MM  = 76.0;

Discovering the Device in Home Assistant

When I opened Home Assistant, I saw a notification to add a discovered ESPHome device. Home Assistant seems to do this quite well — I just had to add the device from the notification.

Building a Dashboard

Lastly, I wanted a dashboard to land on whenever I open Home Assistant. Here’s what I did:

  1. From Settings -> Areas, labels & zones. Created a new area named Terrace.
  2. Back to Settings -> Devices & services -> ESPHome -> Overhead Tank Water Level. Clicked on Edit on the top right and added the area as Terrace.
  3. Back to Settings -> Dashboards. Created a new dashboard. Clicked on Add and selected Terrace -> Overhead Tank Water Level -> Tank 1 Water Level -> Gauge type
Home Assistant's Add to dashboard picker, with Tank 1 Water Level selected and Gauge highlighted among the suggested card types

This is the final dashboard:

Home Assistant dashboard with a single Water Level section showing a gauge card at 53%

What’s Next

  • Mount the sensor in the real tank and fine-tune the calibration constants.
  • Add Tank 2 (second uart: bus on GPIO4).
  • Notifications when the level drops too low.
  • Control the water motor automatically.