Sitemap
Prateek Chauhan

Prateek Chauhan

From Dusty Laptop to Homelab Server

10 min readOct 23, 2026

--

Oct 23, 2026 · @PC

Press enter or click to view image in full size

In two days, my 2015 HP gaming laptop went from a dusty Ubuntu 18.04 desktop to a headless server. It now runs Docker, remote access, Discord alerts and a local AI knowledge base, and it still dual-boots Windows.

I did the work as a pair with Claude Code, an AI agent that runs in the terminal. It ran most commands, and I made the decisions and ran anything that needed sudo. This is the full story, including the parts that went wrong.

The starting point: a 2015 laptop with two lives

The hardware is modest but fine for a beginner’s homelab. The biggest risk was the Windows install sharing the machine.

Laptop’s Hardware specs

Before any command ran, I gave the agent four ground rules:

  1. Never touch the Windows disk. No partitioning, formatting or writing. The only exception is the shared EFI partition, where normal GRUB updates may write Ubuntu’s own files.
  2. Ask before anything destructive or irreversible.
  3. No secrets in any file it creates.
  4. Keep a running log in ~/homelab-setup/PROGRESS.md. A release upgrade means reboots, and each reboot ends the AI session. When I typed "continue", it read the log and picked up where it left off.

The log was the best decision of the project. It survived five reboots and one messy incident, and it became the source for this article.

The first real step was read-only: mapping the disks with lsblk and efibootmgr, then having me confirm which disk was which. Nothing ran until I agreed.

Phase 1: clearing out eight years of clutter

Before upgrading, I removed everything that could break the upgrade or doesn’t belong on a server.

  • Old third-party apt sources. There were 11 of them: Chrome, Steam, a MacBuntu theme PPA, a Java PPA and others. Two already failed on expired GPG keys. They were moved to a backup folder, not deleted.
  • Desktop apps. 31 packages were purged, including Steam, Thunderbird, Rhythmbox and games, plus a few snaps. One snap removal hung on a stuck auto-refresh and only cleared after a reboot.
  • The proprietary Nvidia 390 driver. It is a legacy driver that doesn’t exist in newer Ubuntu releases, so it would have broken the upgrade. The 940M now runs on the open-source nouveau driver. I asked whether the GPU would help with local AI models later. The honest answer: the 940M has 2 GB of VRAM, so the gain is small, and the decision can wait.
  • A health check of the SSD. SMART said PASSED, with 99% life left.
  • Years-old UI tweaks. Someone had themed the desktop long ago, and the terminal was bright white. Resetting it with dconf took a minute, with a backup kept in case I wanted it back.

Tip: remove third-party PPAs and proprietary drivers before a release upgrade. They are the usual cause of failed upgrades.

Phase 2: four release upgrades in a row

Ubuntu only upgrades one LTS release at a time, so 18.04 to 26.04 took four hops. Each hop was the same loop: I ran sudo do-release-upgrade, rebooted, typed "continue", and the agent checked the new system before the next hop.

Ubuntu Upgrade Hops

The checks after each hop were the same every time: release and kernel version, dpkg --audit clean, no held packages, Windows still in grub.cfg.

The hop that went wrong

The last upgrade stopped partway through downloading. The upgrader had already rewritten the apt sources to point at 26.04, and it left the vendor repos (Docker, Tailscale, Cloudflare) disabled. The system itself was still a clean 24.04.

Then I ran apt-get upgrade, out of habit, against those 26.04 sources. It upgraded 1,337 packages to 26.04 but skipped anything that needed new dependencies. The result was a mixed system: the base said 26.04, systemd was still 24.04's, and one package was half-configured because it needed the newer systemd. I sent the agent a message that said: "I think I screwed up, even though apt-get is upgraded to 26.04 systemd is still showing 24.04."

The recovery was calm and methodical:

  1. Diagnose without changing anything. The agent read the dpkg and apt logs to reconstruct exactly what had run and when.
  2. Decide the direction. Downgrading isn’t supported on Ubuntu, so the only way out was forward.
  3. Simulate first. apt full-upgrade -s showed 579 changes and 15 removals, all expected.
  4. Run it in tmux, so a dropped SSH connection couldn't kill it halfway. The old kernel stayed as a boot fallback, and the agent noted that recovery mode gives a root shell if sudo broke (This was my mistake, as I was testing remote sessions via Termius on my mobile and the session dropped midway which resulted in dangling systemd).
  5. Finish the desktop stack with apt install ubuntu-desktop gnome-shell gdm3, which full-upgrade had held back.
  6. Re-enable the vendor repos for the new release, after checking each one already served 26.04.

It took about an hour, and everything I had configured survived.

Lessons: never run apt upgrade after an interrupted release upgrade without first checking /etc/apt/sources.list.d. Run long upgrades inside tmux.

Phase 3: turning a laptop into a server

A laptop wants to sleep, show a desktop and run on battery. A server needs to do none of that.

Press enter or click to view image in full size
Changes for turning the laptop into a server

One trap worth knowing: ports published by Docker bypass ufw entirely. Docker writes its own firewall rules. My template publishes nothing by default, and anything that needs a port binds to 127.0.0.1 only.

I also disabled Samba, a leftover from the desktop days that was still listening on the network.

Remote access without opening a single port

I can reach the server from anywhere, and my router has no port forwards at all.

SSH with keys only. I copied my key from WSL on my main PC with ssh-copy-id. My first logins kept asking for a password anyway. The cause was me: I was mistyping my key's passphrase, so SSH quietly fell back to the password. Two quick tests sorted it out. ssh-keygen -y proved the passphrase, and ssh -o PasswordAuthentication=no proved the key login. Only then did a drop-in file turn password login off:

# /etc/ssh/sshd_config.d/99-homelab.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no

The agent checked the file with sshd -t before reloading, and tested a fresh login afterwards. Keep your current session open while you do this, so a mistake can't lock you out.

Tailscale for private access. It is a WireGuard mesh, so my laptop, PC and phone reach the server on a private address from any network. On Windows, the installer failed with error 0x80070652 (“another installation is in progress”); a reboot fixed it.

Cloudflare Tunnel for public access, later. I bought a domain on Cloudflare and created a tunnel in the Zero Trust dashboard. The server makes an outbound connection to Cloudflare, so nothing listens on my home IP. The tunnel token went straight from the dashboard into cloudflared service install, and the AI never saw it. No public hostnames exist yet; they will be added per app, behind Cloudflare Access.

A small honesty note: the agent once warned that the tunnel token was in a world-readable file. It was wrong. Its search had matched --token inside --token-file, and the token was in a root-only file all along. It corrected itself as soon as I asked to verify its claim. Trust, but verify.

Monitoring: a 96-line script that talks to Discord

A server on a shelf needs to tell you when something breaks. Prometheus and Grafana felt like overkill for one machine, so I went with a bash script, a systemd timer and a Discord webhook.

Get Prateek Chauhan’s stories in your inbox

Join Medium for free to get updates from this writer.

Every 5 minutes, homelab-check looks at:

  • core services: SSH, Docker, containerd, Tailscale, the firewall, unattended upgrades, tlp and time sync, plus extras from a config file (such as cloudflared);
  • any failed systemd unit;
  • disk usage at or above 85%;
  • CPU temperature at or above 85 °C;
  • SSD health from SMART;
  • whether Tailscale is connected;
  • any Docker container that has stopped or is unhealthy, so new apps are covered automatically.

The important design choice: it only messages when the set of problems changes. A failing service sends one alert, not one every 5 minutes, and a recovery message when it is fixed. It also posts a daily heartbeat at 09:00, so silence never means “the monitor died”, and a notice after every boot.

The Discord webhook URL is a secret, so it lives in a root-only file I created myself. It is never in the script.

Press enter or click to view image in full size
Discord alert and its recovery message

First workload: a local AI knowledge base

The first app gives the AI agent a memory of this very server. It is a small RAG project of mine, chatgpt-rag-kb:

  • Ollama runs in Docker, bound to localhost only, with two models: nomic-embed-text for embeddings and llama3.2:3b for answers.
  • ChromaDB stores the vectors.
  • An MCP server exposes two tools to Claude Code: search_kb (find relevant notes) and query_kb (answer from the notes with the local model).

The notes folder links to the setup log, the final report and the apps README. Every future session can look up “how did we set up SSH?” instead of rediscovering it.

A dependency broke on day one. The MCP Python SDK had just released version 2, which renamed the class my code imported. The fix was a one-line pin, mcp<2, which went to GitHub as a pull request. The agent pushed it with a deploy key that can write to that one repo only.

Results on a 2015 CPU:

Press enter or click to view image in full size
RAG results

Indexing the three notes into 22 chunks took 47 seconds. The lesson: on old hardware, use the local model for search, and let a strong model do the reasoning over the results.

What went wrong, and what I’d do differently

Press enter or click to view image in full size

None of these did lasting damage, because every risky step was simulated, backed up or reversible first. The Wi-Fi antenna test even reverted itself automatically when the connection dropped.

Doing sysadmin work with an AI agent

The agent did most of the typing, and I made all of the decisions. That split is what made it work.

What worked

  • Hard rules up front. “Never touch the Windows disk” was never broken. Every step near the boot partition was explained before it ran.
  • A log file as shared memory. Reboots end the session. A plain markdown log with what’s done, what’s next and my decisions let it resume in seconds.
  • Asking before destructive steps. Purges, repo moves and driver removal each got a yes or no from me, with the full list shown first.
  • Least privilege. I ran every sudo command myself from the commands it gave me, often cross-referencing the commands that I had little to no knowledge of what they would do.
  • Secrets stayed with me. The Discord webhook, the tunnel token and the deploy key were created by me and stored in root-only files. The agent only ever referred to the file paths.

Where human judgment mattered

  • Deciding what the machine is for: which apps to remove, whether to keep the desktop, whether to buy a domain.
  • Physical work: removing the battery, plugging in cables, rebooting Windows.
  • Catching overconfidence. The agent was wrong twice and corrected itself both times. But I only noticed because I read what it said instead of just approving.

My honest summary: it is like working with a fast, very well-read junior admin who never gets tired of checking logs. You still need to know enough to ask “are you sure?”

The result, and a checklist you can reuse

The laptop now sits on a shelf with the lid closed, on mains power, running Ubuntu 26.04 with kernel 7.0. Windows is still one menu choice away.

Current setup

Private access comes in through Tailscale, public apps will come through the Cloudflare Tunnel, and problems go out to Discord.

Next on my list: A new and cheap Wi-Fi card for better internet speeds, a DHCP reservation on the router, a BIOS setting to power on after a power cut (ESP32 will do the job), and the first public app behind Cloudflare Access.

Checklist for your own old laptop

  • Map your disks and write down which ones are off-limits.
  • Keep a progress log, especially if an AI agent is helping.
  • Remove PPAs and proprietary drivers before upgrading.
  • Upgrade one LTS release at a time, inside tmux.
  • Boot to console, mask sleep, ignore the lid.
  • Take the battery out from the get-go.
  • Firewall on; remember Docker ports bypass it.
  • Turn on unattended security updates, without automatic reboot.
  • Use SSH keys only, and test before disabling passwords.
  • Use Tailscale and Cloudflare Tunnel instead of port forwarding.
  • Set up alerts that fire on change, with a daily heartbeat.
  • Use Ethernet.

--

--