DEV Community

Kalio Princewill
Kalio Princewill

Posted on

Repurposing My Old Windows Laptop to Fully Functional Homelab

I had an old HP laptop sitting in a drawer. It was running Windows 11 Pro, collecting dust, doing absolutely nothing useful. I had been reading about homelabs for a while, self-hosted services, Docker containers, personal infrastructure and the idea of turning this forgotten machine into something productive kept nagging at me. So one weekend, I finally did it. I wiped Windows, installed Ubuntu Server, set up Docker, deployed a handful of services, and connected the whole thing so I could manage it from anywhere in the world using my MacBook.

The interesting part of this project was the decisions, the tradeoffs between tools, the problems I had to work around, and the small operational details that turned a stack of containers into something I could actually rely on. That's what this article is about.

The Hardware

Before committing to anything, I needed to figure out whether the hardware was even worth the effort. I opened Task Manager on Windows, checked the Performance tab, and pulled these numbers:

  • RAM: 16GB
  • CPU: Intel Core i5-7300U @ 2.60GHz (7th Gen, dual-core with hyperthreading)
  • Storage: 256GB SSD
  • Architecture: 64-bit

16GB of RAM was the number that mattered most. Containers are lightweight individually, but they add up a monitoring stack, a DNS server, a dashboard, a management interface all running simultaneously. 16GB gave me plenty of headroom. The i5-7300U is older, but it supports VT-x and VT-d for hardware virtualization, and for container workloads, clock speed matters far less than available memory. Storage was the only real constraint, 256GB fills up once you start pulling images and persisting data. I made a note to add an external drive eventually, but for the initial build it was more than enough.

Choosing the Operating System

I had three realistic options, and each one involved a different tradeoff.

Proxmox VE is the default recommendation in most homelab communities. It's a Type 1 hypervisor — I'd install it directly on the hardware and run VMs and containers through its web interface. On a rack server or a desktop tower, I'd probably have gone this route. But I was working with a laptop, and Proxmox on laptops introduces friction around lid-switch behavior, battery management, and WiFi driver support that I didn't want to debug on day one. More importantly, I didn't need full VMs. Every service I planned to run ships as a Docker container. Spinning up an entire virtualization layer to run containers felt like over-engineering.

Keeping Windows with WSL2 or Hyper-V was the safest path — I wouldn't lose my Windows installation if I needed it for something. But this machine had been sitting unused for months. I had no reason to keep Windows, and running a Linux subsystem inside Windows adds overhead that I could avoid entirely with a native installation. I wanted the full resources of the machine dedicated to the homelab, not split between two operating systems.

Ubuntu Server was the fit. It boots to a terminal, uses minimal resources, and pairs naturally with Docker. I chose containers over VMs intentionally — on a dual-core machine with 16GB of RAM, containers give me far better service density. The lightweight services I planned to run would consume only a small fraction of available memory. Doing the same with VMs would exhaust my resources after three or four. If I ever outgrow this setup, I can always migrate to Proxmox on proper server hardware. But for a laptop-based homelab running containerized services, Ubuntu Server and Docker is the more efficient architecture.

Getting Ubuntu Onto the Machine

I did all the preparation on my MacBook. I downloaded Ubuntu Server 26.04 LTS from ubuntu.com/download/server about 2.92GB and needed to flash it to a USB drive to boot from.

My first attempt was Balena Etcher, the popular GUI flashing tool. It kept failing with a corrupted archive error. After a few retries, I switched to the terminal and used dd, which writes the ISO directly to the disk at the block level:

diskutil unmountDisk /dev/disk4
sudo dd if=~/Downloads/ubuntu-26.04-live-server-amd64.iso of=/dev/rdisk4 bs=1m
diskutil eject /dev/disk4
Enter fullscreen mode Exit fullscreen mode

The important detail here is the r prefix on rdisk4, it accesses the raw disk device and is significantly faster on macOS. The transfer took about 70 seconds with no visible progress. The terminal looks frozen while dd runs, which is normal, it just has no progress indicator.

I plugged the USB into the HP laptop, booted into the UEFI boot menu (F9 on HP machines), selected the USB drive, and the Ubuntu installer loaded. The installation itself was straightforward: I chose the full Ubuntu Server installation over the minimised variant, selected "Use Entire Disk" to wipe Windows completely, enabled OpenSSH during setup, and named the server kalio-lab. I skipped SSH key import, password authentication is fine to start with, and I can always harden access later with key-based auth.

The one decision worth explaining is OpenSSH. I enabled it during installation rather than installing it afterward because without SSH, the only way to interact with a headless server is through a physical keyboard and monitor. For a machine I planned to manage remotely from day one, SSH access was a requirement.

The WiFi Problem

This was the hardest part of the entire project, and it had nothing to do with Docker or services or infrastructure. It was just getting the server online.

After Ubuntu finished installing and I rebooted, I logged in and ran ip a to check the network. The WiFi adapter showed up as wlp58s0, but with no IP address and state DOWN, NO-CARRIER. No internet connection at all.

Ubuntu Server doesn't connect to WiFi automatically. There's no graphical network manager, no WiFi icon to click, no setup wizard. Networking is configured through files. On most homelabs, this isn't a problem because the machine connects via ethernet and gets an IP from the router with zero configuration. But my HP laptop doesn't have an ethernet port and many modern ultrabooks have dropped it. WiFi was my only path to the internet.

This created a frustrating chicken-and-egg situation. The natural first step would be to install networking tools to scan for available networks with commands like iw or nmcli. But installing packages requires apt, and apt requires an internet connection. I was locked out.

The solution was to skip network scanning entirely and configure the connection directly through Netplan, Ubuntu's network configuration system. The installer had already created a config file at /etc/netplan/00-installer-config.yaml with an empty WiFi section. I opened it with nano and added my network details manually:

network:
  version: 2
  wifis:
    wlp5s0:
      dhcp4: true
      access-points:
        "MyNetworkName":
          password: "MyPassword"
Enter fullscreen mode Exit fullscreen mode

I got my WiFi network name from my MacBook (Option + click the WiFi icon in the menu bar shows the exact SSID). The name had to match perfectly including, capitalization, spaces, and special characters. YAML indentation also had to be exact, using spaces instead of tabs.

I applied the config with sudo netplan apply and checked ip a again. The adapter was showing UP now, but still no IP address. I restarted the networking service:

sudo systemctl restart systemd-networkd
Enter fullscreen mode Exit fullscreen mode

Waited about fifteen seconds. Checked again. And there it was inet 192.168.1.167. The server was on my home network.

What made this problem tricky wasn't the fix itself, Netplan configuration is well-documented. It was the combination of constraints: no GUI, no ethernet port, no pre-installed scanning tools, and a YAML config that's unforgiving about whitespace. If I could give one piece of advice to anyone building a laptop homelab, it would be this: if the machine has an ethernet port, use it for the initial setup. It eliminates this entire class of problems.

Going Remote

The moment I had an IP address, I opened Terminal on my MacBook and typed:

ssh kalio@192.168.1.167
Enter fullscreen mode Exit fullscreen mode

The server's welcome banner appeared:

Welcome to Ubuntu 26.04 LTS
System load:  0.11    Memory usage: 2%    Disk usage: 7%
Temperature: 44.0 C
Enter fullscreen mode Exit fullscreen mode

2% memory usage. 7% disk usage. 44°C. The server was idling comfortably, with nearly all of its resources available. From this point forward, I never needed to touch the HP laptop's keyboard or screen again. Everything I did from here happened through SSH on my MacBook.

Building the Container Runtime

I updated the system first (sudo apt update && sudo apt upgrade -y) and then installed Docker using the official convenience script:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker kalio
newgrp docker
Enter fullscreen mode Exit fullscreen mode

A quick docker ps confirmed the engine was running with empty table, no containers, ready to go.

I used docker run commands throughout this project rather than Docker Compose. For this initial build, I wanted to deploy each service individually so I could understand its operational requirements in isolation: what ports it exposed, what data needed to persist, what permissions it required, and how it interacted with the rest of the stack. Working through each service manually made it easier to troubleshoot issues, and evaluate the role each component played in the overall architecture. Once the stack stabilises, migrating it into Compose files will make it easier to reproduce, version, and maintain.

Making It Manageable — Portainer

Running containers from the command line is fine, but I wanted a visual layer on top of Docker for day-to-day management. I went with Portainer over alternatives like Yacht or Lazydocker because it offers the most complete feature set, container management, image management, volume inspection, network configuration, log viewing, and the ability to deploy new stacks from the web UI. For a homelab where I'm the only operator, the Community Edition covers everything I need.

docker volume create portainer_data

docker run -d \
  -p 8000:8000 \
  -p 9443:9443 \
  --name portainer \
  --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer_data:/data \
  portainer/portainer-ce:latest
Enter fullscreen mode Exit fullscreen mode

The key line is the Docker socket mount (-v /var/run/docker.sock:/var/run/docker.sock). This gives Portainer access to the Docker engine running on the host, which is how it discovers and manages other containers. The --restart=always flag ensures Portainer comes back automatically after a reboot — important for a service that manages all the other services.

I navigated to https://192.168.1.187:9443 in my browser, clicked past the self-signed certificate warning, and created an admin account. With Portainer running, I now had a visual overview of every container, its resource usage, logs, and status, all from a browser tab.

Making It Accessible From Anywhere — Tailscale

Everything I'd built so far only worked on my home WiFi. The address 192.168.1.187 is a private IP, it exists only inside my local network. Walk out the front door, connect to any other network, and the server becomes unreachable.

I had a few options for remote access. Port forwarding would expose my server directly to the public internet by poking a hole in my router's firewall. It works, but it means anyone with my public IP address could attempt to connect. For a home network with other devices on it, that's a risk I wasn't comfortable with. A traditional VPN like OpenVPN or setting up raw WireGuard would work, but requires configuring a VPN server, managing certificates or keys, and dealing with NAT traversal. That felt like more infrastructure to maintain for a simple use case.

I chose Tailscale because it solved the problem with almost zero configuration. Tailscale builds on WireGuard under the hood, so I get the same encryption and performance, but it handles all the key exchange, NAT traversal, and peer discovery automatically. I installed it on the server:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Enter fullscreen mode Exit fullscreen mode

It printed a URL. I opened that URL on my MacBook, authenticated, and the server joined my Tailscale network. Then I installed the Tailscale app on my MacBook and signed in with the same account. Both devices were now on a private mesh network with direct encrypted tunnels between them.

My server's Tailscale IP was 100.121.108.96. I tested it:

ping 100.121.108.96 -c 4
Enter fullscreen mode Exit fullscreen mode

Four packets sent, four received, zero loss, ~2ms latency. That address now works from any network, anywhere. Unlike a public IP exposed through port forwarding, Tailscale addresses are only routable within my personal network. An outside device that isn't authenticated on my Tailscale account can't see that IP, let alone connect to it.

I deliberately chose not to set up port forwarding at all. For now, Tailscale covers my remote access needs completely. If I ever want to share a specific service publicly, like a personal site or an API i'll set up a Cloudflare Tunnel, which provides public access through a domain name without opening any ports on my router. But that's a project for later.

Making It Useful — Monitoring, DNS, and a Dashboard

With the infrastructure layer solid, Docker running, Portainer managing, Tailscale connecting, I moved on to services that actually do something useful. I deployed three in quick succession.

Uptime Kuma — Knowing When Things Break

Running multiple services means things will eventually go down. A container might crash, a disk might fill up, the WiFi connection might drop. I wanted to know about it before I noticed it myself.

I chose Uptime Kuma over more complex monitoring stacks like Prometheus + Grafana because at this stage, I don't need metric aggregation or time-series databases. I need simple "is this service responding?" checks with alerts if something stops. Uptime Kuma does exactly that with a clean UI and minimal resource usage.

docker run -d \
  --name uptime-kuma \
  --restart=always \
  -p 3001:3001 \
  -v uptime-kuma:/app/data \
  louislam/uptime-kuma:1
Enter fullscreen mode Exit fullscreen mode

After creating an account at http://100.121.108.96:3001, I added monitors for each service. One thing I ran into: Portainer uses a self-signed SSL certificate, and Uptime Kuma rejects self-signed certs by default. The fix was toggling off "Verify SSL certificate" in the monitor settings. A small thing, but the kind of detail that will have you staring at a "Down" indicator for ten minutes wondering what's wrong.

Pi-hole — Network-Wide Ad Blocking

Pi-hole intercepts DNS requests across the entire network. When any device tries to resolve a domain, that request passes through Pi-hole first. If the domain matches a known advertising or tracking list, Pi-hole blocks it before anything loads. It works silently across every device without installing ad blockers individually.

I chose Pi-hole over AdGuard Home because Pi-hole has a larger community, more extensive blocklists, and broader documentation. AdGuard has a more modern interface and built-in HTTPS filtering, but for a first homelab DNS server, Pi-hole's ecosystem and community support were more valuable to me.

This deployment was also where I hit my first real infrastructure conflict. The container refused to start because port 53 was already in use. Port 53 is the standard DNS port, and Ubuntu runs its own DNS resolver — systemd-resolved on it by default. Two processes can't bind to the same port.

I needed to disable Ubuntu's resolver and let Pi-hole take over:

sudo systemctl disable systemd-resolved
sudo systemctl stop systemd-resolved
Enter fullscreen mode Exit fullscreen mode

This sounds like I was ripping out a critical system component, but I was really just replacing it with a more capable one. Pi-hole handles DNS resolution for the same purposes that systemd-resolved did, plus it blocks ads and provides query logging. And the change is completely reversible, two commands to re-enable the original resolver if I ever remove Pi-hole.

After clearing the port conflict and cleaning up the failed container with docker rm, the redeployment worked without issues. The Pi-hole admin dashboard came up at http://100.121.108.96:8080/admin.

docker run -d \
  --name pihole \
  --restart=always \
  -p 53:53/tcp \
  -p 53:53/udp \
  -p 8080:80 \
  -e TZ="Africa/Lagos" \
  -e WEBPASSWORD="MySecurePassword" \
  -v pihole_data:/etc/pihole \
  -v dnsmasq_data:/etc/dnsmasq.d \
  pihole/pihole:latest
Enter fullscreen mode Exit fullscreen mode

Homarr — One Page to Find Everything

By this point, I was managing five different services across five different ports. I kept forgetting whether Uptime Kuma was on 3001 or 3010, whether Portainer was HTTP or HTTPS. Homarr gave me a single homepage with tiles for every service, each linking directly to its web interface.

docker run -d \
  --name homarr \
  --restart=always \
  -p 7575:7575 \
  -v homarr_configs:/app/data/configs \
  -v homarr_icons:/app/public/icons \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/ajnart/homarr:latest
Enter fullscreen mode Exit fullscreen mode

It's a small quality-of-life addition, but it's the difference between a collection of services and something that feels like a coherent platform. I bookmarked http://100.121.108.96:7575 and it became my single entry point to the homelab.

Making It Headless

The final step was turning the HP laptop into something I could close, put on a shelf, and forget about physically. By default, closing the lid triggers a suspend, which kills everything.

I edited /etc/systemd/logind.conf and changed the lid switch behavior:

HandleLidSwitch=ignore
Enter fullscreen mode Exit fullscreen mode

Then applied it:

sudo systemctl restart systemd-logind
Enter fullscreen mode Exit fullscreen mode

I also masked all sleep-related targets to prevent the server from suspending for any other reason:

sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
Enter fullscreen mode Exit fullscreen mode

After this, I closed the lid, walked to my MacBook, SSH'd in, and everything was running exactly as I'd left it. The screen turns off when the lid closes, but the server keeps humming. It now sits on a shelf, lid closed, power cable plugged in, completely silent and completely operational.

What I'm Running

Here's the full stack:

Service Port Role
Portainer 9443 Container management UI
Uptime Kuma 3001 Service health monitoring
Pi-hole 8080 Network-wide DNS and ad blocking
Homarr 7575 Homelab dashboard and service directory
Tailscale Encrypted remote access mesh

Total memory usage across all services: barely a dent in 16GB. The CPU idles most of the time. The 256GB SSD is at 7% usage with significant room to grow.

What I Haven't Done Yet (And Why)

A homelab is never finished, and there are things I intentionally deferred rather than forgot about.

Backups. Right now, my container data lives on Docker volumes with no backup strategy. If the SSD fails, I lose everything. Setting up automated backups, even something as simple as a cron job that tars the volumes and pushes them to cloud storage is the first thing I plan to address. The services themselves are easy to redeploy, but the data inside them (Pi-hole blocklists, Uptime Kuma monitors, Portainer settings) would take time to recreate manually.

Docker Compose. As I mentioned earlier, I used individual docker run commands deliberately to learn how each service is configured. The natural next step is to define the entire stack in a docker-compose.yml file. That gives me version-controlled infrastructure if the machine dies.

Static IP assignment. My server gets its local IP from my router's DHCP server, which means it could theoretically change. I need to either set a static IP in Netplan or create a DHCP reservation on my router. It hasn't been a problem yet because Tailscale gives me a stable address regardless, but it's worth doing properly.

Proper secrets management. My Pi-hole password is set as a plaintext environment variable in the docker run command. That's visible in the process list and Docker inspect output. For a personal homelab behind Tailscale, the risk is low, but best practice is to use Docker secrets or at minimum an .env file that isn't stored in version control.

Automatic updates. Right now, updating a container means manually pulling the new image and recreating the container. Tools like Watchtower can automate this, but unattended updates on infrastructure services make me uneasy, I'd rather review changelogs before updating my DNS server. I'm still thinking about the right balance here.

What's Next

Two things genuinely excite me about where this homelab goes from here.

The first is running local AI models with Ollama and Open WebUI. Ollama lets me pull and run open-source language models like Llama 3 directly on the server, and Open WebUI puts a clean ChatGPT-style interface on top of it. My i5 doesn't have a GPU, so inference will be slow on larger models, but smaller models should be perfectly usable, and there's something compelling about having a completely private AI chat running on hardware I own in my own home.

The second is migrating the entire stack to Docker Compose and version-controlling it in Git. Right now, my homelab's configuration exists as a series of commands I ran in a terminal session. If the machine died today, I'd have to rebuild everything from memory. Moving to Compose files in a Git repository means my infrastructure is documented, reproducible, and portable.

Conclusion

The most interesting thing about this project wasn't that I deployed a few containers. It was realizing how much infrastructure can run comfortably on hardware that most people would throw away. The laptop that spent months in a drawer is now a DNS server, monitoring system, remote-access gateway, and container host. More importantly, it's become a low-risk environment for learning the operational side of infrastructure engineering.

Top comments (2)

Collapse
 
dumebii profile image
Dumebi Okolo

This is super impressive!!

Collapse
 
kalio profile image
Kalio Princewill

I am glad you liked it