From an Old PC to Your Own VPS
A complete, concept-first path to turning a spare computer + your home Wi-Fi + a domain you own into a real self-hosted server — hosting your own websites, Node.js backends, and databases, reachable on the internet with proper HTTPS and security.
depends_on (§10), Cloudflare orange vs grey cloud & cert challenges (§7–8), encoding-safe SSH-key copy from PowerShell (§9). New depth — LXC vs VM (§3), Docker log rotation (§6), IPv6 as a CGNAT escape hatch (§1/§5), consumer-SSD/ZFS wear (§2), netplan + cloud-init gotchas (§5). New v2 tags mark the changed bits.0 · The mental model: what you're actually building
Before touching anything, get the picture clear. A server is not a special kind of computer — it's just a computer that stays on and answers requests from other computers (serving web pages, files, APIs). Your old PC is perfectly capable of this.
A VPS (Virtual Private Server) — the thing companies like DigitalOcean, Linode, or AWS rent you — is a slice of a big physical server, carved out using virtualization. You rent a virtual machine that behaves like its own independent computer. You're going to build the same thing, at home, on hardware you own.
The spectrum: NAS → appliance → hypervisor
People come to home servers from different directions. Here's the landscape so you know where you're sitting:
| Approach | What it is | Best for | Tradeoff |
|---|---|---|---|
| NAS (TrueNAS, Synology) | A box focused on storage — network file shares, with some apps bolted on | Backups, file storage, media libraries | Storage-first; app hosting is secondary |
| Single-OS server (just Ubuntu) | One operating system, your apps run directly on it | Simplicity, one job, low overhead | One mistake affects everything; hard to isolate projects |
| Hypervisor (Proxmox) | Bare-metal layer that runs many VMs/containers — your own mini cloud | Learning, isolation, running many independent things | One extra concept to learn; slightly more RAM overhead |
The stack you'll end up with
Every layer in that diagram is a section in this guide. Let's build it from the bottom up.
1 · Core concepts you must own
90% of home-server pain is networking confusion. Spend 20 minutes here and the rest of the guide clicks into place. These are the foundations a VPS provider normally hides from you — owning your own server means owning these.
IP addresses: private vs public
Every device on a network has an IP address. There are two worlds:
- Private IPs — used inside your home network. Always in these ranges:
10.x.x.x,172.16–31.x.x, or most commonly192.168.x.x. Your laptop, phone, and server each get one (e.g.192.168.1.42). The outside world cannot reach these directly. - Public IP — the single address your whole house shows to the internet, assigned by your ISP to your router. Everyone in your home shares it.
NAT: how one public IP serves a whole house
NAT (Network Address Translation) is the trick your router uses to let many private devices share one public IP. When your laptop requests a web page, the router rewrites the request to come from the public IP, remembers it, and routes the reply back. This works great for outgoing traffic.
yourdomain.com, the request hits your router — but the router doesn't automatically know to send it to your server. Solving this is the entire point of Section 8. You either tell the router explicitly (port forwarding) or have the server reach out (Cloudflare Tunnel).CGNAT: the thing that breaks port forwarding
Some ISPs (especially mobile, fibre, and newer providers) don't even give your router a real public IP. They put you behind Carrier-Grade NAT — your router shares a public IP with hundreds of other customers. You literally cannot open a port to yourself. We'll teach you how to detect this in Section 5; if you have it, Cloudflare Tunnel is your answer and you skip port forwarding entirely.
/64 block). If yours does, you can publish an AAAA record pointing straight at your server's IPv6 address and serve directly — no tunnel, no port-forward problem — to any visitor whose own connection has IPv6. The catch: visitors on IPv4-only networks (still common on mobile and corporate Wi-Fi) can't reach an IPv6-only service, so it's not a full substitute for Cloudflare Tunnel. But it's a genuine, free option worth knowing about. We test for it in Section 5.Ports: the doors on a machine
An IP gets you to a machine; a port gets you to a specific service on it. Think apartment number after the street address. The ones you'll use constantly:
| Port | Service |
|---|---|
22 | SSH — remote terminal access to your server |
80 | HTTP — unencrypted web |
443 | HTTPS — encrypted web (what you'll actually serve) |
8006 | Proxmox web admin panel |
DNS: turning names into addresses
DNS is the internet's phone book. It translates yourdomain.com into an IP address. You bought a domain — that means you control its DNS records. The records you'll touch:
| Record | Purpose | Example |
|---|---|---|
| A | Points a name → IPv4 address | yourdomain.com → 102.x.x.x |
| AAAA | Points a name → IPv6 address | yourdomain.com → 2c0f:... |
| CNAME | Points a name → another name (alias) | www → yourdomain.com |
Static IP, dynamic IP, and DDNS
Two more terms before we build:
- Static vs dynamic public IP: most home ISPs give you a dynamic public IP — it changes occasionally. That's a problem if your DNS record points at a fixed number. The fix is DDNS (Dynamic DNS): a small program updates your DNS record automatically whenever your IP changes. (Cloudflare Tunnel sidesteps this entirely.)
- Static internal IP: inside your home, you want your server to always have the same private IP (e.g. always
192.168.1.10) so the router and other devices can find it reliably. Section 5 covers this.
2 · Hardware prep: getting the old PC ready
Is the PC good enough?
Almost certainly yes. A home server hosting websites, a Node API, and a database needs far less than a gaming PC. Rough guidance:
| Resource | Bare minimum | Comfortable | Why |
|---|---|---|---|
| CPU | Any 64-bit dual-core | Quad-core+ | Must support virtualization (almost all do since ~2012) |
| RAM | 8 GB | 16 GB+ | Proxmox itself uses ~1–2 GB; each VM/app needs its share |
| Disk | 120 GB SSD | SSD for OS + bigger HDD for data | SSD makes everything feel responsive |
| Network | Wi-Fi works | Wired Ethernet | See warning below |
Practical realities
- It will run 24/7. Think about power cost, heat, and noise — put it somewhere ventilated and tolerable. An old desktop idling costs a few dollars/month in electricity.
- Get a UPS eventually. A small uninterruptible power supply protects against sudden power cuts that can corrupt your disks and databases. Not required on day one, strongly recommended once you store anything you care about.
- Back up anything on the PC now. Installing Proxmox erases the entire disk. Rescue your files first.
Step 1 — Enable virtualization in the BIOS
To run VMs, your CPU's virtualization feature must be switched on. It's often off by default.
- Restart the PC and press the BIOS/UEFI key during boot — usually Del, F2, F10, or F12 (the screen usually tells you).
- Find a setting called Intel VT-x / Intel Virtualization Technology, or on AMD SVM Mode. Enable it.
- (Optional, for passing devices to VMs later) enable VT-d / AMD-Vi (IOMMU).
- If the Proxmox installer won't boot, try disabling Secure Boot — it can block the unsigned installer on some boards.
- Set the boot order so USB boots first (you'll need this next), then save & exit.
Step 2 — Make a bootable USB installer
You'll download an OS image (an .iso file) and "flash" it to a USB stick (8 GB+). On Windows the easiest tools are:
- balenaEtcher — dead simple, works for any ISO. Recommended for beginners.
- Rufus — Windows-native, more options.
- Ventoy — pro move: copy multiple ISOs onto one stick and pick at boot. Great because you'll be trying both Proxmox and Ubuntu.
3 · Proxmox vs Ubuntu — and how they stack
You asked to learn both. Here's the honest comparison, then the recommended path that uses both together.
| Proxmox VE | Ubuntu Server | |
|---|---|---|
| What it is | A bare-metal hypervisor — its whole job is running VMs & containers, managed from a web UI | A general-purpose Linux OS you run apps on directly |
| You manage it via | Web browser (https://ip:8006) + terminal | Terminal (SSH) only |
| Run many isolated apps? | Yes — spin up a new VM/container per project, instantly | Harder — everything shares one OS (Docker helps) |
| Snapshots / easy rollback | Built in — snapshot a VM before risky changes, roll back in seconds | Not built in |
| Learning curve | One extra layer to grasp | Lower — it's "just Linux" |
| Feels most like a VPS provider? | ✅ Yes — this is what they run | It's the thing a VPS gives you |
The recommended path: Proxmox as the base, Ubuntu as a VM inside it
This gives you the best of both, and mirrors real infrastructure:
- Install Proxmox on the bare metal (the old PC). This is your "cloud."
- From Proxmox's web UI, create an Ubuntu Server VM. This is your "VPS instance."
- Run your apps (Docker, Node, databases) inside that Ubuntu VM.
Why this is worth the extra layer:
- Snapshots: before any risky change, snapshot the VM. Broke something? Roll back in 10 seconds.
- Isolation: want to experiment without endangering your live website? Spin up a second throwaway VM. Delete it when done.
- It's reversible: learning Ubuntu inside a VM means a mistake never bricks the whole machine.
Install Proxmox VE (the foundation)
- Download the Proxmox VE ISO from proxmox.com/downloads and flash it to USB (Section 2).
- Boot the PC from the USB. Choose Install Proxmox VE (Graphical).
- Accept the license, pick the target disk (⚠ it erases this disk) and filesystem (
ext4orzfs— see the SSD-wear warning in Section 2), set country/timezone. - Set a strong root password and a real email (for alerts).
- Management network: it will pick your wired interface. Give the server a static private IP outside your router's auto-assign range — e.g. IP
192.168.1.10, gateway = your router (often192.168.1.1), netmask255.255.255.0. Write this IP down — it's your server's permanent address. - Finish, remove the USB, reboot. The console will show a line like:
# on the server's screen after boot https://192.168.1.10:8006/ - From your laptop (same network), open that URL in a browser. You'll get a certificate warning — that's expected for a self-signed local cert; click through. Log in as user
rootwith the password you set, realm "Linux PAM".
pve-enterprise repo, and add the pve-no-subscription one (button provided). It's fully functional without paying. Community Proxmox helper scripts automate this and other post-install tidy-ups.Create your Ubuntu Server VM
- Download the Ubuntu Server LTS ISO from ubuntu.com/download/server (LTS = Long Term Support, the stable 5-year-supported version — pick the newest LTS).
- In Proxmox web UI: select your node → local storage → ISO Images → Upload, and upload that Ubuntu ISO.
- Click Create VM (top right). Walk through:
- OS: select the Ubuntu ISO you uploaded.
- System: defaults are fine; tick QEMU Guest Agent. v2 Note: ticking this only tells Proxmox to expect an agent — you must also install it inside Ubuntu (step 6 below), or Proxmox can't read the VM's IP or shut it down gracefully.
- Disk: 40–60 GB to start. Leave the bus as VirtIO SCSI (Proxmox's default "VirtIO SCSI single" controller) for the best disk performance.
- CPU: 2 cores. Memory: 4096 MB (4 GB) to start.
- Network: leave the default bridge
vmbr0with the VirtIO (paravirtualized) model.
- Start the VM, open its Console, and run the Ubuntu installer: accept defaults, set a hostname (e.g.
webserver), create your user, and tick "Install OpenSSH server" so you can connect remotely. Skip the bundled snaps. - After install, shut down, remove the ISO from the VM's CD drive (Hardware tab), and start it again. In the console, log in and find its IP:
Note theip a192.168.x.xaddress — this VM is your "server" from here on. - v2 Install the guest agent inside the VM so Proxmox can read its IP and shut it down cleanly:
Back in the Proxmox Summary tab you should now see the VM's IP address reported.sudo apt update && sudo apt install qemu-guest-agent -y sudo systemctl enable --now qemu-guest-agent
4 · Linux survival kit
From here you'll work over SSH — a secure remote terminal — instead of the Proxmox console. This is how all server work is done.
Connect with SSH
From your Windows machine, open PowerShell or Windows Terminal:
# replace with YOUR username and the VM's IP
ssh [email protected]
Type yes to trust it the first time, then your password. You're now controlling the server.
The commands you'll use every day
# Where am I / what's here
pwd # print working directory
ls -lah # list files, human-readable sizes, incl. hidden
cd /path/to/dir # change directory
# Files
nano file.txt # simple text editor (Ctrl+O save, Ctrl+X exit)
cat file.txt # print a file
cp a b / mv a b / rm file # copy / move / delete
mkdir mydir # make a folder
# System awareness
htop # live CPU/RAM view (install: sudo apt install htop)
df -h # disk space
free -h # memory
sudo journalctl -xe # recent system logs (great for debugging)
Updating the system (do this first, and regularly)
On Ubuntu/Debian, software is installed and updated with apt. sudo runs a command as administrator.
sudo apt update # refresh the list of available packages
sudo apt upgrade -y # install all available updates
sudo apt install htop curl git ufw -y # install a few essentials
Users & sudo
You should never run things as root day-to-day. Work as your normal user and use sudo when you need admin rights.
whoami # which user am I
sudo adduser deploy # create a user (e.g. for deployments)
sudo usermod -aG sudo deploy # grant it admin (sudo) rights
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades5 · Networking your server
Give the server a fixed internal IP
Your server must always have the same private IP so DNS, port forwarding, and the reverse proxy can rely on it. Two ways — the first is preferred:
- DHCP reservation (recommended, easiest): in your router's admin page, find the DHCP / "connected devices" section, locate your server by its MAC address, and "reserve" / "bind" its current IP. The router will always hand it the same one. This keeps all your IP config in one place.
- Static IP on the server itself: configure it in Ubuntu via netplan. (If you used a DHCP reservation, skip this.)
# Find your interface name and current IP first
ip a
# Edit netplan config (filename may vary)
sudo nano /etc/netplan/50-cloud-init.yaml
# Example netplan static config — match indentation EXACTLY (YAML is strict)
network:
version: 2
ethernets:
ens18: # your interface from `ip a`
dhcp4: false
addresses: [192.168.1.50/24]
routes:
- to: default
via: 192.168.1.1 # your router
nameservers:
addresses: [1.1.1.1, 8.8.8.8]
sudo netplan apply
sudo chmod 600 /etc/netplan/*.yaml. (2) cloud-init fights you: the default Ubuntu cloud image lets cloud-init re-manage the network on every boot, which can revert your static IP. Pin your config by disabling cloud-init's network takeover:
echo 'network: {config: disabled}' | sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
After that your netplan file wins permanently across reboots.⭐ Find out your internet situation (the question you couldn't answer)
This determines whether port forwarding will even work for you. Do all three checks:
Check 1 — your true public IP
From the server, ask the internet what IP it sees you as:
curl https://ifconfig.me ; echo
Check 2 — what IP your router thinks it has
Log into your router's admin page and find the WAN / Internet IP.
Check 3 v2 — do you have working public IPv6?
Even if IPv4 is behind CGNAT, you may have routable IPv6 (see Section 1):
curl -6 https://ifconfig.co ; echo # prints your IPv6, or errors if you have none
If that returns a 2xxx:... address rather than an error, you likely have a routable IPv6 — a possible direct path (AAAA record) for IPv6-capable visitors.
Interpret the result
| What you see | Meaning | What to do |
|---|---|---|
Router WAN IP matches ifconfig.me, and it's a normal public IP | ✅ You have a real public IP — port forwarding will work | Either Section 8 path works |
Router WAN IP is 100.64.x.x – 100.127.x.x | ⚠ You're behind CGNAT | Port forwarding won't work → use Cloudflare Tunnel |
Router WAN IP differs from ifconfig.me (and isn't yours) | ⚠ Likely CGNAT or double-NAT | Use Cloudflare Tunnel (or call ISP to request a public IP) |
| v2 Behind CGNAT on IPv4, but Check 3 returned a public IPv6 | ⚠ IPv4 inbound blocked, IPv6 open | Cloudflare Tunnel for universal reach, or an AAAA record direct for IPv6 visitors |
6 · Docker & Docker Compose — how you'll run everything
Docker packages an app and everything it needs into a container — a lightweight, isolated bundle that runs identically anywhere. Docker Compose describes several containers (your Node app + database + reverse proxy) in one file and runs them together. This is the modern way to deploy, and exactly what you asked for.
Install Docker
# Official convenience installer
curl -fsSL https://get.docker.com | sudo sh
# Run docker without sudo every time (log out/in after)
sudo usermod -aG docker $USER
# Verify (Compose v2 ships built in as `docker compose`)
docker --version
docker compose version
v2 Stop containers from filling your disk (do this now)
By default Docker keeps every container's logs forever in an ever-growing JSON file. One chatty container can quietly eat the whole disk and take the server down at 3am. Set a global cap once, before you run anything important:
sudo nano /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
sudo systemctl restart docker # existing containers pick this up next time they're (re)created
That keeps at most 3 × 10 MB of logs per container. Combine it with the disk-usage habit of occasionally running docker system df and docker system prune.
Your first container
docker run -d --name hello -p 8080:80 nginx
# -d detached, --name names it, -p maps host:container ports
# Visit http://192.168.1.50:8080 from your laptop — you'll see the nginx page.
docker ps # list running containers
docker logs hello # see its output
docker stop hello && docker rm hello # stop and remove
-p 8080:80 is now reachable from your whole LANPublishing a port with -p binds it to all interfaces (0.0.0.0) by default, and — critically — that port punches straight through the ufw firewall (explained in Section 9). For anything that only needs to be reached locally or by the reverse proxy, publish to localhost instead: -p 127.0.0.1:8080:80. Keep this in mind from your very first container.The Compose mental model
Instead of long docker run commands, you write a docker-compose.yml file describing your whole stack, then bring it up with one command. Each app gets a folder:
mkdir -p ~/apps/myproject && cd ~/apps/myproject
nano docker-compose.yml
services:
web:
image: nginx:latest
ports:
- "8080:80"
restart: unless-stopped
docker compose up -d # start in background
docker compose ps # status
docker compose logs -f # follow logs (Ctrl+C to stop watching)
docker compose down # stop & remove the stack
We'll build a real Node.js + PostgreSQL + reverse-proxy Compose stack in Section 10. First, your domain and HTTPS.
7 · Your domain & DNS
You own a domain — now point it at your server. The single best move here is to put your domain behind Cloudflare (free), because it gives you DNS management, hides your home IP, provides free DDoS protection, and enables Cloudflare Tunnel.
Put your domain on Cloudflare (recommended)
- Create a free account at dash.cloudflare.com and click Add a site; enter your domain.
- Cloudflare scans your existing DNS and gives you two nameservers (like
xxx.ns.cloudflare.com). - Go to where you bought the domain (the registrar) and replace its nameservers with Cloudflare's two. This delegates DNS control to Cloudflare. (Propagation: minutes to a day.)
v2 The orange cloud vs the grey cloud (this matters)
In Cloudflare's DNS table, every A/AAAA/CNAME record has a cloud-icon toggle. It changes everything about how traffic flows:
- Orange cloud (Proxied): visitors hit Cloudflare, which forwards to you. It hides your home IP, adds free DDoS protection and caching — but Cloudflare only proxies HTTP/HTTPS on standard web ports, the free plan caps request body uploads at 100 MB, and because Cloudflare terminates TLS itself, a reverse proxy's automatic HTTP-01 certificate challenge can't validate the usual way.
- Grey cloud (DNS only): Cloudflare just answers the DNS query; the visitor then connects straight to your IP. Your home IP is exposed in DNS, but Let's Encrypt HTTP-01 works normally and there's no upload cap.
Create the DNS records
How you fill these depends on which Section 8 path you take:
- Cloudflare Tunnel path: the tunnel creates the DNS record for you automatically — you'll do almost nothing here. (Skip ahead to Section 8 Path A.)
- Port-forwarding path: create an A record: name
@(the root domain) → your public IP. Add another A recordwwwor a CNAMEwww → yourdomain.com. (Start them grey-cloud — see above.)
If your public IP changes: Dynamic DNS
On the port-forwarding path with a dynamic IP, run a DDNS updater so your A record follows your IP. With Cloudflare you can use a small container like oznu/cloudflare-ddns with an API token, or a router with built-in DDNS. (Tunnel users don't need this at all — another reason it's popular.)
api.yourdomain.com (Node backend), yourdomain.com (website), cloud.yourdomain.com (Nextcloud), etc. The reverse proxy in the next section routes each name to the right container.8 · Exposing to the internet — both paths, with tradeoffs
This is the heart of "making it a VPS." Two ways to let the internet reach your server. Learn both; pick one.
| Path A — Cloudflare Tunnel | Path B — Port forwarding + DDNS | |
|---|---|---|
| How it works | A daemon on your server makes an outbound connection to Cloudflare; traffic flows back down that tunnel | You open ports on your router so the internet connects inbound to your server |
| Open router ports? | ❌ None — nothing inbound | ✅ Yes (80, 443) |
| Works behind CGNAT? | ✅ Yes | ❌ No |
| Hides your home IP? | ✅ Yes | ❌ No (your IP is public in DNS) |
| HTTPS | Automatic via Cloudflare | You issue certs (Let's Encrypt via the proxy) |
| Control / "rawness" | Less raw; depends on Cloudflare | Full control, the classic "real server" feeling |
| Best for | Most people, anyone behind CGNAT, safety-first | Learning networking deeply, no third party in the path |
Both paths need a reverse proxy first
A reverse proxy sits in front of all your apps. It receives every incoming request, reads which domain was asked for, and forwards it to the correct container — while handling HTTPS centrally. Without it, only one app could use port 443. Options:
| Caddy | Simplest — automatic HTTPS with one line per site. Recommended. |
| Nginx Proxy Manager | Has a friendly web GUI; great if you prefer clicking. |
| Traefik | Docker-native, configured by container labels; powerful, steeper curve. |
Caddy reverse proxy via Compose (recommended)
# ~/apps/proxy/docker-compose.yml
services:
caddy:
image: caddy:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- web
volumes:
caddy_data:
caddy_config:
networks:
web:
external: true
# Create the shared network once, so all apps can join it:
docker network create web
# ~/apps/proxy/Caddyfile — Caddy auto-fetches Let's Encrypt HTTPS certs!
yourdomain.com {
reverse_proxy website:3000
}
api.yourdomain.com {
reverse_proxy api:4000
}
Caddy automatically obtains and renews real HTTPS certificates from Let's Encrypt — no manual cert work. (On Path B this requires ports 80/443 reachable from the internet; on Path A the tunnel handles HTTPS, so you can have Caddy just serve HTTP internally.)
caddy-dns/cloudflare module and use the DNS-01 challenge (an API token) so it works even while proxied. On the Tunnel path this is moot — Cloudflare issues the public certificate for you.Path A — Cloudflare Tunnel (safe, CGNAT-proof)
- In the Cloudflare dashboard go to Zero Trust → Networks → Tunnels → Create a tunnel. Name it (e.g.
home). - Cloudflare shows an install command for
cloudflared. The cleanest way is to run it as a container in Compose:# ~/apps/cloudflared/docker-compose.yml services: cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN=eyJ...your-token-from-the-dashboard... networks: - web networks: web: external: true - Back in the dashboard, under the tunnel's Public Hostnames, add:
yourdomain.com→ servicehttp://caddy:80(or directlyhttp://website:3000)api.yourdomain.com→http://api:4000
- Bring it up:
docker compose up -d. Visithttps://yourdomain.com— you're live, with HTTPS, no open ports.
Path B — Port forwarding + DDNS (the classic way)
- Make sure your server has a fixed internal IP (Section 5).
- In your router's admin page, find Port Forwarding / Virtual Server. Add two rules:
- External port
80→ internal192.168.1.50:80 - External port
443→ internal192.168.1.50:443
- External port
- Point your domain's A record at your public IP (Section 7), grey-cloud during cert issuance. If your IP is dynamic, set up DDNS so it stays current.
- Caddy (already running) will now fetch Let's Encrypt certificates over port 80/443 and serve HTTPS. Visit
https://yourdomain.com.
9 · Security hardening — the expert dividing line
The difference between a hobbyist and an expert is here. A server on the internet is probed within minutes. None of this is optional once you're exposed.
1 — SSH keys instead of passwords
Passwords get brute-forced. SSH keys are effectively unguessable. Generate a key on your laptop, copy the public half to the server, then disable password login.
# On your Windows machine (PowerShell):
ssh-keygen -t ed25519 # press enter for defaults; set a passphrase
# Copy the PUBLIC key to the server. (v2) Read it into a variable and send it
# over an ssh command — DON'T use `type pub | ssh ... >> file`, because
# PowerShell redirection can add a UTF-16/BOM and CRLF that corrupt the key.
$pub = Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub"
ssh [email protected] "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$pub' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
Confirm you can log in without a password, then harden the SSH daemon:
sudo nano /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no
sudo systemctl restart ssh # on newer Ubuntu this also reloads the ssh.socket
PasswordAuthentication no, or you can lock yourself out. With Proxmox you have a safety net: the VM console in the web UI still lets you in.2 — Firewall (only open what you use)
Ubuntu's ufw makes this simple. Default: block everything inbound, allow what you need.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH # keep your SSH access!
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
-p 8080:80, or ports: in Compose), Docker writes its own iptables rules into a chain that is evaluated before ufw's rules. So a container published on 0.0.0.0 is reachable from your LAN — and from the internet on Path B — even though ufw status shows that port as "deny." Your ufw rules genuinely protect the host's own services (SSH, etc.), but not published containers. Three correct fixes:
- Bind to localhost for anything that only the reverse proxy or the host needs: write
"127.0.0.1:8080:80"instead of"8080:80". The database and app containers in Section 10 should not publish a host port at all. - Only ever publish 80/443 (the proxy). Everything else talks over the internal Docker network and is never published — exactly the pattern this guide uses.
- If you must firewall a published port with ufw, use ufw-docker or add rules to the
DOCKER-USERiptables chain (Docker leaves that chain for you and respects it).
sudo ufw allow from 192.168.1.0/24 to any port 22. With Cloudflare Tunnel you never expose SSH publicly at all.3 — fail2ban (auto-ban attackers)
sudo apt install fail2ban -y
# It auto-protects SSH out of the box: repeated failed logins → temporary IP ban.
sudo systemctl status fail2ban
4 — The habits that matter most
- Patch constantly — enable unattended-upgrades (Section 4); update Docker images regularly.
- Least privilege — don't run as root; give each app only the access it needs; use strong, unique DB passwords.
- Don't expose admin panels — Proxmox (8006), databases (5432/3306), and dashboards must never be port-forwarded. Reach them over your LAN, a VPN, or Cloudflare Access only.
- Secrets out of code — keep passwords/API keys in a
.envfile (and add it to.gitignore), never commit them. - Back up — the ultimate security control is being able to restore (Section 11).
Your threat model (think like a defender)
Be honest about what you're defending against. For a home server it's almost never a targeted hacker — it's automated bots scanning the whole internet for known holes. SSH keys + firewall + patching + not exposing admin panels (and not accidentally publishing a container past ufw) defeats ~99% of that. The remaining risk is misconfigured apps and weak passwords — which is why least-privilege and secrets-hygiene matter.
10 · Real stacks: your website, a Node.js API, and a database
This is your actual goal. Here's a complete, production-shaped Compose stack: a Node.js backend, a PostgreSQL database, your static website, all behind the Caddy proxy from Section 8, on the shared web network.
Project layout
~/apps/
proxy/ # Caddy (from Section 8)
docker-compose.yml
Caddyfile
myapp/
docker-compose.yml
.env # secrets — never commit
api/ # your Node.js source + Dockerfile
website/ # your static site files
The app stack
# ~/apps/myapp/docker-compose.yml
services:
api:
build: ./api # builds your Node.js Dockerfile
restart: unless-stopped
environment:
- DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/appdb
- NODE_ENV=production
depends_on:
db:
condition: service_healthy # (v2) wait until Postgres actually accepts connections
networks: [web, internal]
db:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=appdb
volumes:
- db_data:/var/lib/postgresql/data # data survives restarts
healthcheck: # (v2) defines "healthy" for the wait above
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 10
networks: [internal] # NOT on web → never internet-exposed (and no host port published)
website:
image: nginx:latest
restart: unless-stopped
volumes:
- ./website:/usr/share/nginx/html:ro
networks: [web]
volumes:
db_data:
networks:
web:
external: true
internal: {}
# ~/apps/myapp/.env (chmod 600; add to .gitignore)
DB_PASSWORD=use-a-long-random-string-here
depends_on: [db] (what v1 had) only waits for the database container to start — not for Postgres to finish initializing and start accepting connections. On a fresh boot the API often launches a few seconds too early, fails to connect, and crash-loops (or worse, starts in a broken state). Pairing condition: service_healthy with a Postgres healthcheck (pg_isready) makes Compose hold the API back until the DB is genuinely ready. Your app should still retry connections on its own — containers can restart any time — but this removes the most common first-boot failure.A minimal Node.js Dockerfile
# ~/apps/myapp/api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 4000
CMD ["node", "server.js"]
Wire it into Caddy & launch
Your Caddyfile already routes yourdomain.com → website and api.yourdomain.com → api:4000 (Section 8). Bring everything up:
cd ~/apps/myapp
docker compose up -d --build
docker compose logs -f api # watch it boot, Ctrl+C to stop watching
internal, so it's reachable by the API but never by the proxy or the internet — and because it publishes no host port, the Docker-bypasses-ufw trap (Section 9) can't expose it either. The API and website are on web so Caddy can reach them. This network separation is a core pattern — keep databases off any internet-facing network and off any published host port.A deploy workflow (level up)
For real development, push code with git and redeploy:
# On the server, in ~/apps/myapp
git pull
docker compose up -d --build # rebuild changed images, restart
docker image prune -f # clean old layers
Later you can automate this with a GitHub Actions runner or a webhook — but a manual git pull && docker compose up -d --build is a perfectly respectable starting deploy.
Other services you can drop in the same way
Each is "add a service block to a Compose file + a Caddy route + a subdomain." Once you've done the Node+DB stack, you can host almost anything. Browse awesome-selfhosted for ideas.
11 · Backups & reliability
Self-hosting means you are the ops team. A server you can't restore is a liability. This is non-negotiable once you store anything real.
The 3-2-1 rule
The industry standard: 3 copies of your data, on 2 different media, with 1 off-site. For a home server in practice:
- Copy 1: live data on the server.
- Copy 2: automated local backup (Proxmox snapshot/backup, or a backup to a second disk/NAS).
- Copy 3: off-site — encrypted upload to cloud storage (Backblaze B2, S3, etc.).
Layer 1 — Proxmox VM backups
Proxmox can back up entire VMs on a schedule and restore them whole. In the web UI: Datacenter → Backup → Add, pick the VM, a storage target, and a schedule. To roll back, restore the backup — the entire VM returns. (For serious setups, run a separate Proxmox Backup Server for deduplicated, incremental backups.)
Layer 2 — Database dumps
Backing up the VM disk is not always enough for a live database; take logical dumps too:
# PostgreSQL dump from the running container
docker compose exec db pg_dump -U app appdb > backup-$(date +%F).sql
Layer 3 — Off-site, encrypted
Tools like restic or BorgBackup do encrypted, deduplicated, incremental backups to cloud storage. Schedule them with a cron job. The golden rule:
Monitoring & uptime
- Uptime Kuma — a beautiful self-hosted status monitor; pings your sites and alerts you (email/Telegram/Discord) when something goes down. Easiest first monitoring tool.
- Prometheus + Grafana — the pro stack for graphs of CPU/RAM/disk/traffic over time. Add it once you want dashboards.
restart: unless-stoppedin Compose (used throughout this guide) means containers auto-restart after a crash or power cut.
12 · Operate & grow — becoming an expert
Document everything (your future self will thank you)
Keep a plain-text or markdown "runbook" in a git repo: what's installed where, what each subdomain points to, IP addresses, how to restore from backup. When something breaks at 11pm in six months, this file is gold. (This very project lives in your Homelab repo — a perfect home for it.)
Infrastructure as Code — the next level
Right now you configure things by hand. The expert habit is to make your setup reproducible from files:
- Compose files in git — you're already doing this; it means your whole app stack is version-controlled and re-deployable.
- Ansible — describe your server's entire setup (packages, users, configs) as code, so you can rebuild it identically on new hardware in minutes.
- Reproducibility test: the goal is that if the PC died, you could rebuild everything from your git repo + backups, not from memory.
When to scale up
| Signal | Move |
|---|---|
| RAM constantly full | Add RAM (cheapest upgrade), or move heavy services to a second VM |
| Running out of disk | Add a dedicated data drive; consider a NAS (TrueNAS) for storage |
| Need true 24/7 uptime for others | Consider a cheap real VPS for the public-facing piece, keep home server for private data |
| Multiple machines | Learn clustering (Proxmox cluster) or container orchestration (Docker Swarm, then k3s/Kubernetes) |
Your expert roadmap (in order)
- ✅ Get the Proxmox → Ubuntu → Docker → domain → HTTPS stack live (this guide).
- Add monitoring (Uptime Kuma) and automated backups. Test a restore.
- Move all configs into git; learn a CI/CD deploy flow.
- Add a VPN (WireGuard / Tailscale) for secure remote admin instead of exposing SSH.
- Learn Ansible to make the whole box reproducible.
- Explore k3s (lightweight Kubernetes) when you outgrow single-host Compose.
Communities & resources
- r/selfhosted and r/homelab — the central communities
- awesome-selfhosted — catalogue of self-hostable apps
- Docker docs · Proxmox docs · Caddy docs
Glossary
| Term | Plain-English meaning |
|---|---|
| Server | A computer that stays on and answers requests from other machines |
| VPS | A virtual machine rented from a provider that acts as your own server |
| Hypervisor | Software (e.g. Proxmox) that runs multiple virtual machines on one physical computer |
| VM | Virtual Machine — a simulated computer with its own OS, running on a hypervisor |
| LXC | A lightweight Proxmox system container that shares the host kernel — boots in ~1s, far lighter than a VM (different from a Docker container) |
| Container | A lightweight isolated app bundle (Docker) sharing the host's OS kernel |
| Bare metal | Software running directly on the physical hardware, not in a VM |
| Private IP | An address used only inside your home network (192.168.x.x etc.) |
| Public IP | The single address your whole house presents to the internet |
| IPv6 | The newer, vast address space; often routable even when IPv4 is behind CGNAT |
| NAT | Router trick letting many private devices share one public IP |
| CGNAT | ISP-level NAT where you share a public IP with many customers; blocks port forwarding |
| Port | A numbered "door" identifying a specific service on a machine |
| Port forwarding | Router rule sending inbound traffic on a port to a specific internal device |
| DNS | The system mapping domain names to IP addresses |
| A / AAAA / CNAME | DNS record types: name→IPv4 / name→IPv6 / name→another name |
| Orange / grey cloud | Cloudflare DNS toggle: proxied (hides IP, caps uploads) vs DNS-only (direct connect) |
| DDNS | Dynamic DNS — auto-updates a DNS record when your IP changes |
| SSH | Secure encrypted remote terminal access to a server |
| Reverse proxy | Front-door service routing requests to the right app + handling HTTPS |
| TLS / HTTPS | Encryption for web traffic; the padlock in the browser |
| Let's Encrypt | Free automated provider of HTTPS certificates |
| HTTP-01 / DNS-01 | The two ways Let's Encrypt proves you own a domain: via port 80, or via a DNS record |
| Firewall (ufw) | Rules controlling which network connections are allowed in/out (note: Docker can bypass it) |
| DOCKER-USER chain | The iptables chain Docker leaves for your firewall rules over published container ports |
| Healthcheck | A command Compose runs to decide if a container is truly "ready", not just started |
| 3-2-1 backup | 3 copies, 2 media types, 1 off-site |
Command cheat-sheet
# --- Connect ---
ssh [email protected]
# --- System ---
sudo apt update && sudo apt upgrade -y # update everything
htop # live resources
df -h ; free -h # disk ; memory
sudo journalctl -xe # recent logs
ip a # network interfaces / IPs
# --- Firewall ---
sudo ufw status verbose
sudo ufw allow 443/tcp
# (remember: ufw does NOT block ports published by Docker -p)
# --- Docker ---
docker ps # running containers
docker compose up -d --build # build + start a stack
docker compose logs -f # follow logs
docker compose down # stop + remove stack
docker system df # what's eating disk
docker image prune -f # clean old images
# --- Checks ---
curl https://ifconfig.me # my public IPv4 (CGNAT check)
curl -6 https://ifconfig.co # my public IPv6 (if any)
ping 1.1.1.1 # is the internet reachable
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
Can't reach Proxmox at :8006 | Wrong IP, or not on the same network. Check the server screen for the URL; ensure your laptop is on the same LAN. Accept the self-signed cert warning. |
| Proxmox doesn't show the VM's IP / won't shut it down cleanly | v2 The QEMU guest agent isn't installed inside the VM. sudo apt install qemu-guest-agent && sudo systemctl enable --now qemu-guest-agent. |
| Static IP reverts to DHCP after reboot | v2 cloud-init is re-managing the network. Disable it (Section 5) and re-check netplan file permissions. |
| Domain doesn't resolve | DNS hasn't propagated, or record is wrong. Wait; check with nslookup yourdomain.com. Lower the TTL. |
| Site works on LAN but not from internet | Port forward missing/wrong, CGNAT, or router can't loop back. Test from mobile data. If CGNAT → use Cloudflare Tunnel. |
| HTTPS cert fails (Path B) | Ports 80/443 not reachable, or v2 the record is orange-cloud so the HTTP-01 challenge hits Cloudflare not Caddy. Set the record grey-cloud during issuance, or use the DNS-01 challenge. Confirm forwarding + firewall + DNS. |
| v2 A container is reachable even though ufw says "deny" | Docker bypasses ufw for published ports (Section 9). Bind it to 127.0.0.1:, stop publishing it, or use ufw-docker / the DOCKER-USER chain. |
| v2 API container crash-loops right after boot | It started before Postgres was ready. Add a DB healthcheck + depends_on: condition: service_healthy (Section 10), and make the app retry connections. |
| Locked out of SSH | You disabled passwords before keys worked. Use the Proxmox VM console to fix sshd_config. |
| Container won't start | docker compose logs <service> almost always tells you exactly why (bad env var, port clash, missing file). |
| Database data disappeared after restart | You forgot the named volume — data lived inside the ephemeral container. Always mount a volume for DBs. |
| Disk slowly fills up / server dies overnight | v2 Unbounded container logs. Set log rotation in /etc/docker/daemon.json (Section 6); check with docker system df. |
| Server unreachable after power cut | Add restart: unless-stopped to containers; set the PC's BIOS to "power on after AC loss"; get a UPS. |
Master checklist
Tick these off as you go (mentally, or copy into your runbook):
Foundation
- Backed up any files on the old PC
- Connected the PC to the router by Ethernet cable
- Enabled virtualization (VT-x / SVM) in BIOS
- Chose a sensible filesystem at install (ext4 vs ZFS, given your SSD)
- Installed Proxmox; can reach the web UI at
:8006 - Created an Ubuntu Server VM with SSH enabled
- Installed qemu-guest-agent inside the VM; Proxmox shows its IP
Network & access
- Server has a fixed internal IP (DHCP reservation or static)
- netplan locked in (file chmod 600, cloud-init network disabled)
- Determined whether you're behind CGNAT (and whether you have public IPv6)
- Domain pointed at Cloudflare (nameservers changed)
- Understand the orange vs grey cloud toggle for your chosen path
- SSH key login works; passwords + root login disabled
Apps online
- Docker + Compose installed and working
- Docker log rotation configured in
/etc/docker/daemon.json - Caddy reverse proxy running
- Chosen an exposure path (Tunnel or port-forward) and it works from outside
- Website + Node API + database stack live with HTTPS (DB on internal network only)
Don't stop here
- Firewall (ufw) enabled; only 80/443 (and LAN-only SSH) open
- Verified no container publishes to
0.0.0.0except the proxy's 80/443 - fail2ban + unattended-upgrades installed
- Automated backups configured AND a restore tested
- Monitoring (Uptime Kuma) alerting you on downtime
- Configs committed to your Homelab git repo