Goal: Transform an old laptop into a fully functional 24/7 home server with no GUI, hosting Docker containers, accessible from anywhere in the world for free, without a static IP.
- Chapter 1: Base Installation
- Chapter 2: Container Environment Setup
- Chapter 3: Deploying Core Services
- Chapter 4: Networking & Bypassing CGNAT
Since the laptop is old, the main goal was to squeeze every drop out of those specs to serve applications, not waste them on a graphical desktop environment we'd never use anyway.
Downloading the OS: We went to the official Debian website and downloaded Debian Netinst (Network Installer) — a lightweight image (~600MB) that only installs the bare essentials, pulling the rest from the internet during setup. Download from here: 'www.debian.org'
Burning the image: We used Rufus (or BalenaEtcher) on the main Windows machine to flash the ISO onto a USB drive.
- Plugged the USB into the old laptop.
- Powered it on and hit the Boot Menu key (usually
F12orF2), then selected boot from USB. - Started the installation. The first steps are standard:
- Language: English (preferred for servers — logs stay readable)
- Timezone: Jordan
- Username:
mosakrmov+ password - Disk partitioning:
Guided - use entire disk
This was the most critical moment of the entire install. When we reached the Software Selection screen:
| Option | Action |
|---|---|
Debian desktop environment |
❌ Uncheck |
GNOME / Xfce |
❌ Uncheck |
SSH server |
✅ Check |
Standard system utilities |
✅ Keep checked |
Why? A desktop environment alone eats ~1.5GB of RAM out of our 4GB total. By skipping it, the OS now idles at under 200MB RAM, freeing over 75% of memory for actual applications.
After installation completed and the system rebooted:
On the laptop (one time only): Log in and find the local IP:
ip a
# or
hostname -I
# Result: 192.168.100.86And that was the last time we touched the old laptop. :)
From the main Windows machine, we opened PowerShell and typed:
ssh mosakrmov@192.168.100.86From this point on, the old laptop became just a "server" sitting in the corner, and all work happens remotely from the main machine.
Since it's a laptop, closing the lid puts it to sleep and kills the server. Fix:
sudo nano /etc/systemd/logind.confFind the line:
#HandleLidSwitch=suspend
Remove the # and change the value to:
HandleLidSwitch=ignore
Save the file (Ctrl+X → Y → Enter), then apply:
sudo systemctl restart systemd-logindNow you can close the lid, shove the laptop under the bed, and it runs 24/7.
After successfully SSHing into the server, the system was a clean slate consuming almost no resources. The next step was turning it into a modern container-based hosting environment.
Before anything else, update packages and install basic tools:
sudo apt update && sudo apt upgrade -y
sudo apt install curl git nano -yInstead of a complex manual installation, we used Docker's official convenience script:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.shOptional: Run Docker commands without sudo every time:
sudo usermod -aG docker mosakrmov
# Then log out and back inSince the server has no GUI, managing dozens of containers purely through the terminal can get exhausting. Portainer provides a professional web-based GUI for managing Docker from any browser.
Create a volume to persist Portainer's settings:
sudo docker volume create portainer_dataRun the Portainer container:
sudo docker run -d \
-p 8000:8000 \
-p 9000:9000 \
--name portainer \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
portainer/portainer-ce:latestTechnical note: Mounting
docker.sockinside the container allows Portainer to communicate directly with the Docker engine on the host system.
From the browser on the main machine:
http://192.168.100.86:9000
Create an admin account, and from this moment we can start, stop, and delete any container with a single click.
With a container environment ready, we started deploying the applications that make up our Home Lab.
Instead of uploading files through complex scp commands, we ran FileBrowser as our own personal Google Drive alternative.
The challenge: We hit a 403 Forbidden error when trying to upload files, caused by a permissions conflict between Linux and the container.
The fix: We used the hurlenko/filebrowser image and passed our system user identity (PUID=1000) to the container:
sudo docker run -d \
--name filebrowser \
--restart=always \
-p 8080:8080 \
-v /home/mosakrmov:/data \
-v fb_config:/config \
-e PUID=1000 \
-e PGID=1000 \
hurlenko/filebrowserwe can skip the auth if we want by adding --noauth after specifying the image
Access: http://192.168.100.86:8080
As an engineer, I have a large collection of PDF references (engineering textbooks, Islamic studies volumes, etc.). Kavita is a fast, resource-light E-Reader platform:
sudo docker run -d \
--name kavita_reader \
--restart=always \
-p 5001:5000 \
-v /home/mosakrmov/movies:/data \
-v kavita_config:/kavita/config \
jvmilazz0/kavita:latestAccess: http://192.168.100.86:5001
An open-source media server for organizing and streaming movies and TV shows to any screen or mobile device on the local network:
sudo docker run -d \
--name jellyfin_cinema \
--restart=always \
-p 8096:8096 \
-v /home/mosakrmov/movies:/media \
-v jellyfin_config:/config \
-v jellyfin_cache:/cache \
jellyfin/jellyfinAccess: http://192.168.100.86:8096
Migrated personal projects off paid cloud hosting onto this server, Dockerize your project and run it in the server!
The biggest challenge for any home server: accessing it from outside the home (like from campus) without a Static IP — especially with CGNAT from ISPs, which makes traditional Port Forwarding impossible.
[You at university]
|
| Want to reach...
↓
[Server at home]
← Hidden behind CGNAT
← No Static IP
← Port Forwarding not possible
We used an open-source tool called Zrok. It opens a secure reverse tunnel from the local server to Zrok's global infrastructure, giving us a stable HTTPS-encrypted public URL.
Install Zrok on the server:
curl -sSLf https://get.zrok.io | bash
zrok enable <your-token>Start the tunnel:
# Wrong — caused Connection Refused due to IPv6
zrok share public localhost:9000
# Correct — explicitly targets IPv4
zrok share public 127.0.0.1:9000The smart fix: To avoid IPv6-related
Connection Refusederrors, we explicitly pointed Zrok at127.0.0.1instead oflocalhost.
Zrok URLs are great, but they look random and are hard to remember — something like abc123xyz.share.zrok.io.
Instead of dealing with traditional DNS configuration or paying for a domain, we used a free Hacker Way trick:
Steps:
-
Created a Firebase Hosting project (free tier) to get a clean URL:
mosa-lab.web.app -
Configured
firebase.jsonto instantly redirect to our Zrok URL:{ "hosting": { "redirects": [ { "source": "/**", "destination": "https://abc123xyz.share.zrok.io/:path*", "type": 301 } ] } } -
Deployed:
firebase deploy
[You at university]
|
| Type: mosa-lab.web.app
↓
[Firebase Hosting]
|
| Instant 301 Redirect
↓
[Zrok Tunnel — HTTPS encrypted]
|
| Reverse Tunnel
↓
[Your home server]
All of this:
- ✅ Zero cost
- ✅ No static IP needed
- ✅ No router configuration required
- ✅ Automatic HTTPS encryption
| Service | Port | Description |
|---|---|---|
| Portainer | 9000 |
Docker management web UI |
| FileBrowser | 8080 |
Personal file storage |
| Kavita | 5001 |
Book & PDF library |
| Jellyfin | 8096 |
Media server (movies & shows) |
# List all running containers
sudo docker ps
# Live resource usage
sudo docker stats
# Restart a specific container
sudo docker restart jellyfin_cinema
# View recent logs for a container
sudo docker logs filebrowser --tail 50
# Update a container to the latest image
sudo docker pull jellyfin/jellyfin
sudo docker stop jellyfin_cinema
sudo docker rm jellyfin_cinema
# Then re-run the original docker run command