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.

Built for someone who understands the idea but not the implementation. By the end you'll understand both, and operate like an expert.  ·  Version 2 — deeper and corrected.
💡 How to use this guide: read top-to-bottom the first time — each section builds on the last. Code blocks have a Copy button. Look for the colored boxes: Tip, Warning, Security, Critical. Don't paste a command you don't understand — every block has an explanation above it.
🆕 What's new in v2 (vs v1): corrected and expanded for technical accuracy. Highlights — the Docker-bypasses-the-firewall trap (§9), QEMU guest agent must be installed inside the VM (§3), Postgres readiness vs 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:

ApproachWhat it isBest forTradeoff
NAS (TrueNAS, Synology)A box focused on storage — network file shares, with some apps bolted onBackups, file storage, media librariesStorage-first; app hosting is secondary
Single-OS server (just Ubuntu)One operating system, your apps run directly on itSimplicity, one job, low overheadOne mistake affects everything; hard to isolate projects
Hypervisor (Proxmox)Bare-metal layer that runs many VMs/containers — your own mini cloudLearning, isolation, running many independent thingsOne extra concept to learn; slightly more RAM overhead
Your pathYou'll learn both Proxmox and Ubuntu, because they stack together. Proxmox is the foundation; Ubuntu runs inside it; your apps run inside Ubuntu. This is exactly how the pros isolate workloads — and exactly what a cloud provider does under the hood.
Go deeperWant the theory behind the hypervisor before you install one? The hypervisor docs cover it from first principles: what a hypervisor is (Type 1 vs Type 2), how virtualization works (VT-x/AMD-V, KVM, passthrough), choosing a platform, and Proxmox in practice.

The stack you'll end up with

┌─────────────────────────────────────────────────────────┐ │ THE INTERNET · yourdomain.com │ └───────────────┬─────────────────────────────────────────┘ │ (Cloudflare Tunnel OR port-forward) ┌───────▼────────┐ │ Your router │ home Wi-Fi / LAN └───────┬────────┘ ┌────────────▼─────────────────────────────────────────┐ │ OLD PC · PROXMOX VE (the hypervisor / bare metal) │ │ ┌──────────────────────┐ ┌────────────────────────┐ │ │ │ Ubuntu Server VM │ │ (future) another VM │ │ │ │ ┌─────────────────┐ │ │ e.g. a test box, │ │ │ │ │ Docker + Compose│ │ │ a Windows VM, etc. │ │ │ │ │ • Caddy (proxy)│ │ └────────────────────────┘ │ │ │ │ • Node.js API │ │ │ │ │ │ • PostgreSQL │ │ │ │ │ │ • your website │ │ │ │ │ └─────────────────┘ │ │ │ └──────────────────────┘ │ └───────────────────────────────────────────────────────┘

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 commonly 192.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.

The home-server problemNAT means incoming connections from the internet don't know which internal device to reach. When someone visits 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.

Tip v2 — IPv6 can be your escape hatch from CGNATMany ISPs that put you behind CGNAT on IPv4 still hand out a real, globally-routable IPv6 address (often a whole /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:

PortService
22SSH — remote terminal access to your server
80HTTP — unencrypted web
443HTTPS — encrypted web (what you'll actually serve)
8006Proxmox 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:

RecordPurposeExample
APoints a name → IPv4 addressyourdomain.com → 102.x.x.x
AAAAPoints a name → IPv6 addressyourdomain.com → 2c0f:...
CNAMEPoints a name → another name (alias)www → yourdomain.com
Tip — DNS is slow to changeDNS records cache (TTL, time-to-live). After you change a record it can take minutes to hours to take effect everywhere. Set a low TTL (e.g. 300s / 5 min) while you're setting things up.

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:

ResourceBare minimumComfortableWhy
CPUAny 64-bit dual-coreQuad-core+Must support virtualization (almost all do since ~2012)
RAM8 GB16 GB+Proxmox itself uses ~1–2 GB; each VM/app needs its share
Disk120 GB SSDSSD for OS + bigger HDD for dataSSD makes everything feel responsive
NetworkWi-Fi worksWired EthernetSee warning below
Warning — use a wired Ethernet cable if you possibly canYou said you have home Wi-Fi. A server can run on Wi-Fi, but it's a bad idea: Wi-Fi drops, has higher latency, and (importantly) Proxmox does not support Wi-Fi well — it expects a wired connection for its bridged networking. Run an Ethernet cable from the PC to your router. If that's truly impossible, you can use a powerline adapter or a Wi-Fi-to-Ethernet bridge, but wired is the expert default.

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.
Warning v2 — cheap SSDs + Proxmox's default ZFS wear out fastAt install, Proxmox asks which filesystem to put the OS on. The default ZFS is excellent — checksums catch silent corruption, plus snapshots and compression — but it has high write amplification and keeps a constant write-heavy journal. A budget/QLC SSD with no DRAM cache (or, worse, a USB flash drive) can be worn out in months under that load. Two safe choices: (1) use a decent SSD with DRAM from a known brand and ZFS is fine; or (2) if your disk is modest and you don't need ZFS's features, pick plain ext4 on LVM at install — lighter on writes. Either way, never install Proxmox onto a USB stick for daily use.

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.

  1. Restart the PC and press the BIOS/UEFI key during boot — usually Del, F2, F10, or F12 (the screen usually tells you).
  2. Find a setting called Intel VT-x / Intel Virtualization Technology, or on AMD SVM Mode. Enable it.
  3. (Optional, for passing devices to VMs later) enable VT-d / AMD-Vi (IOMMU).
  4. If the Proxmox installer won't boot, try disabling Secure Boot — it can block the unsigned installer on some boards.
  5. 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.
TipFlashing erases the USB stick. The process is: download ISO → open the flashing tool → select ISO → select USB → flash → wait. Then plug it into the server PC and boot from it.

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 VEUbuntu Server
What it isA bare-metal hypervisor — its whole job is running VMs & containers, managed from a web UIA general-purpose Linux OS you run apps on directly
You manage it viaWeb browser (https://ip:8006) + terminalTerminal (SSH) only
Run many isolated apps?Yes — spin up a new VM/container per project, instantlyHarder — everything shares one OS (Docker helps)
Snapshots / easy rollbackBuilt in — snapshot a VM before risky changes, roll back in secondsNot built in
Learning curveOne extra layer to graspLower — it's "just Linux"
Feels most like a VPS provider?✅ Yes — this is what they runIt'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:

  1. Install Proxmox on the bare metal (the old PC). This is your "cloud."
  2. From Proxmox's web UI, create an Ubuntu Server VM. This is your "VPS instance."
  3. 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.
v2 VM vs LXC container — Proxmox gives you bothProxmox can run two kinds of guest: full VMs (KVM — a complete virtual computer with its own kernel and a real boot process) and LXC containers (lightweight system containers that share the host's kernel — they boot in about a second and use a fraction of the RAM). LXC is a fantastic fit for a single, simple service. For this guide we deliberately use a VM, because we'll run Docker inside it: Docker-inside-LXC works but needs nesting/keyctl tweaks and gives weaker isolation, while Docker-inside-a-VM is the boring, bulletproof default. Once you're comfortable, reach for LXC for lightweight one-offs (a DNS resolver, a small utility) to stretch your RAM further. Note the terminology clash: a Proxmox "container" (LXC) is a different thing from a "Docker container" — LXC virtualizes a whole OS userland; Docker packages a single app.
If you want to start simplerYou can skip Proxmox and install Ubuntu Server directly on the PC for your first week, to focus purely on Linux + Docker. Then later wipe and go Proxmox-first once you're comfortable. Both are valid — but the Proxmox-first path is what an expert builds, so we'll do that and note the Ubuntu-direct differences as we go.

Install Proxmox VE (the foundation)

  1. Download the Proxmox VE ISO from proxmox.com/downloads and flash it to USB (Section 2).
  2. Boot the PC from the USB. Choose Install Proxmox VE (Graphical).
  3. Accept the license, pick the target disk (⚠ it erases this disk) and filesystem (ext4 or zfs — see the SSD-wear warning in Section 2), set country/timezone.
  4. Set a strong root password and a real email (for alerts).
  5. 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 (often 192.168.1.1), netmask 255.255.255.0. Write this IP down — it's your server's permanent address.
  6. 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/
  7. 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 root with the password you set, realm "Linux PAM".
Tip — the "No subscription" popup & the free repoProxmox is free and open-source. On login it nags about a paid subscription — just click OK. To get updates without paying, switch to the free "no-subscription" repository: in the UI go to your node → Updates → Repositories, disable the 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

  1. 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).
  2. In Proxmox web UI: select your node → local storage → ISO ImagesUpload, and upload that Ubuntu ISO.
  3. 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 vmbr0 with the VirtIO (paravirtualized) model.
  4. 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.
  5. 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:
    ip a
    Note the 192.168.x.x address — this VM is your "server" from here on.
  6. v2 Install the guest agent inside the VM so Proxmox can read its IP and shut it down cleanly:
    sudo apt update && sudo apt install qemu-guest-agent -y
    sudo systemctl enable --now qemu-guest-agent
    Back in the Proxmox Summary tab you should now see the VM's IP address reported.
Ubuntu-direct usersIf you skipped Proxmox, you just installed this same Ubuntu directly on the PC instead of in a VM (and you can ignore the guest-agent step — that's Proxmox-only). Everything from Section 4 onward is identical.

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
Tip — enable automatic security updatesSo your server patches known vulnerabilities while you sleep:
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades

5 · 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
Warning v2 — two netplan gotchas that revert your IP(1) File permissions: recent netplan refuses to fully trust a world-readable config and prints "permissions are too open … your configuration may be exposed." Silence it and protect any credentials with 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 seeMeaningWhat to do
Router WAN IP matches ifconfig.me, and it's a normal public IP✅ You have a real public IP — port forwarding will workEither Section 8 path works
Router WAN IP is 100.64.x.x100.127.x.x⚠ You're behind CGNATPort forwarding won't work → use Cloudflare Tunnel
Router WAN IP differs from ifconfig.me (and isn't yours)⚠ Likely CGNAT or double-NATUse 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 openCloudflare Tunnel for universal reach, or an AAAA record direct for IPv6 visitors
Good news either wayIf you're behind CGNAT, you're not stuck — Cloudflare Tunnel (Section 8, Path A) works perfectly behind CGNAT, costs nothing, and is actually the safer option. Many experts choose it even when they could port-forward.

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.

Containers vs VMsA VM (what Proxmox makes) virtualizes a whole computer — its own OS. A container (what Docker makes) shares the host's OS kernel and just isolates the app — far lighter, starts in milliseconds. You'll use VMs for big boundaries and containers for individual apps. Together: Ubuntu VM → many Docker containers inside it.

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
Warning v2 — that -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)

  1. Create a free account at dash.cloudflare.com and click Add a site; enter your domain.
  2. Cloudflare scans your existing DNS and gives you two nameservers (like xxx.ns.cloudflare.com).
  3. 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.
Which to useCloudflare Tunnel path: the tunnel creates records that are already proxied — leave them orange, and Cloudflare handles HTTPS for you (no local cert needed). Port-forward path: the simplest start is grey (DNS-only) so Caddy can fetch its Let's Encrypt cert over port 80; once HTTPS works you can flip to orange and switch Caddy to the DNS-01 challenge (a Cloudflare API token) or install a Cloudflare Origin Certificate. See Section 8 for the exact interaction.

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 record www or a CNAME www → 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.)

Tip — subdomains for each appPlan to give each service its own name: 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 TunnelPath B — Port forwarding + DDNS
How it worksA daemon on your server makes an outbound connection to Cloudflare; traffic flows back down that tunnelYou 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)
HTTPSAutomatic via CloudflareYou issue certs (Let's Encrypt via the proxy)
Control / "rawness"Less raw; depends on CloudflareFull control, the classic "real server" feeling
Best forMost people, anyone behind CGNAT, safety-firstLearning networking deeply, no third party in the path
RecommendationStart with Path A (Tunnel) — it's safer, works everywhere, and gets you online today. Then, as a learning exercise, set up Path B on a test subdomain to truly understand ports, firewalls, and certificates. That's how you become expert: use the safe default, but understand the raw mechanism.

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:

CaddySimplest — automatic HTTPS with one line per site. Recommended.
Nginx Proxy ManagerHas a friendly web GUI; great if you prefer clicking.
TraefikDocker-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.)

Warning v2 — the cert gotcha on the port-forward pathCaddy gets its free cert through an HTTP-01 challenge on port 80. That needs two things at once: port 80 reachable from the internet, and the DNS record set to grey-cloud / DNS-only during issuance (see Section 7). If the record is proxied (orange), the challenge request lands on Cloudflare instead of Caddy and validation fails with a cert error. Two ways through: (1) keep the record grey while Caddy issues, or (2) build Caddy with the 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)

  1. In the Cloudflare dashboard go to Zero Trust → Networks → Tunnels → Create a tunnel. Name it (e.g. home).
  2. 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
  3. Back in the dashboard, under the tunnel's Public Hostnames, add:
    • yourdomain.com → service http://caddy:80 (or directly http://website:3000)
    • api.yourdomain.comhttp://api:4000
    Cloudflare creates the DNS records automatically.
  4. Bring it up: docker compose up -d. Visit https://yourdomain.com — you're live, with HTTPS, no open ports.
Security — Tunnel gives you Zero Trust for freeYou can put Cloudflare Access in front of admin panels (Proxmox UI, databases, dashboards) so they require a login/email code before anyone reaches them — even though they're "on the internet." Use this for anything not meant for the public.

Path B — Port forwarding + DDNS (the classic way)

Before you do thisOpening ports exposes your server directly to the entire internet, which constantly scans for vulnerable machines. Do not proceed past here until you've done Section 9 (hardening). Only forward ports 80 and 443 — never forward SSH (22) or the Proxmox panel (8006) to the internet.
  1. Make sure your server has a fixed internal IP (Section 5).
  2. In your router's admin page, find Port Forwarding / Virtual Server. Add two rules:
    • External port 80 → internal 192.168.1.50:80
    • External port 443 → internal 192.168.1.50:443
  3. 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.
  4. Caddy (already running) will now fetch Let's Encrypt certificates over port 80/443 and serve HTTPS. Visit https://yourdomain.com.
Tip — test from outside your networkSome routers can't "loop back" to your own public IP from inside the house. Test the public URL from your phone on mobile data, not home Wi-Fi, to confirm it really works from the internet.

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
WarningMake 100% sure key login works before setting 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
Critical v2 — Docker punches straight through UFWThis surprises almost everyone and it's the most dangerous gap in most home-server setups. When you publish a container port (-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-USER iptables chain (Docker leaves that chain for you and respects it).
Security — restrict SSH furtherBest practice is to only allow SSH from your home LAN, not the whole world: 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
v2 Note — fail2ban + Dockerfail2ban watches host log files. It protects host SSH perfectly, but it can't see inside container logs by default, and (per the box above) bans added as iptables rules may not sit in front of Docker's published-port traffic. For app-level brute-force protection, rely on the reverse proxy (rate-limiting), Cloudflare, or the app itself — not host 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 .env file (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
Warning v2 — why the healthcheck mattersA bare 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
Security — note the two networksThe database is only on 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

Nextcloud — your own cloud drive Jellyfin — media streaming Uptime Kuma — status monitoring Vaultwarden — password manager Gitea — your own GitHub n8n — automation

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.)

Tip — snapshot before risky changesRight-click a VM → Snapshot → Take Snapshot before any upgrade. If it breaks, roll back in seconds. This safety net is the biggest practical reason to run Proxmox.

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:

A backup you haven't restored is not a backupTest a restore. Periodically pull a backup down and confirm it actually works. Untested backups fail exactly when you need them.

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-stopped in 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

SignalMove
RAM constantly fullAdd RAM (cheapest upgrade), or move heavy services to a second VM
Running out of diskAdd a dedicated data drive; consider a NAS (TrueNAS) for storage
Need true 24/7 uptime for othersConsider a cheap real VPS for the public-facing piece, keep home server for private data
Multiple machinesLearn clustering (Proxmox cluster) or container orchestration (Docker Swarm, then k3s/Kubernetes)

Your expert roadmap (in order)

  1. ✅ Get the Proxmox → Ubuntu → Docker → domain → HTTPS stack live (this guide).
  2. Add monitoring (Uptime Kuma) and automated backups. Test a restore.
  3. Move all configs into git; learn a CI/CD deploy flow.
  4. Add a VPN (WireGuard / Tailscale) for secure remote admin instead of exposing SSH.
  5. Learn Ansible to make the whole box reproducible.
  6. Explore k3s (lightweight Kubernetes) when you outgrow single-host Compose.

Communities & resources


Glossary

TermPlain-English meaning
ServerA computer that stays on and answers requests from other machines
VPSA virtual machine rented from a provider that acts as your own server
HypervisorSoftware (e.g. Proxmox) that runs multiple virtual machines on one physical computer
VMVirtual Machine — a simulated computer with its own OS, running on a hypervisor
LXCA lightweight Proxmox system container that shares the host kernel — boots in ~1s, far lighter than a VM (different from a Docker container)
ContainerA lightweight isolated app bundle (Docker) sharing the host's OS kernel
Bare metalSoftware running directly on the physical hardware, not in a VM
Private IPAn address used only inside your home network (192.168.x.x etc.)
Public IPThe single address your whole house presents to the internet
IPv6The newer, vast address space; often routable even when IPv4 is behind CGNAT
NATRouter trick letting many private devices share one public IP
CGNATISP-level NAT where you share a public IP with many customers; blocks port forwarding
PortA numbered "door" identifying a specific service on a machine
Port forwardingRouter rule sending inbound traffic on a port to a specific internal device
DNSThe system mapping domain names to IP addresses
A / AAAA / CNAMEDNS record types: name→IPv4 / name→IPv6 / name→another name
Orange / grey cloudCloudflare DNS toggle: proxied (hides IP, caps uploads) vs DNS-only (direct connect)
DDNSDynamic DNS — auto-updates a DNS record when your IP changes
SSHSecure encrypted remote terminal access to a server
Reverse proxyFront-door service routing requests to the right app + handling HTTPS
TLS / HTTPSEncryption for web traffic; the padlock in the browser
Let's EncryptFree automated provider of HTTPS certificates
HTTP-01 / DNS-01The 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 chainThe iptables chain Docker leaves for your firewall rules over published container ports
HealthcheckA command Compose runs to decide if a container is truly "ready", not just started
3-2-1 backup3 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

SymptomLikely cause & fix
Can't reach Proxmox at :8006Wrong 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 cleanlyv2 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 rebootv2 cloud-init is re-managing the network. Disable it (Section 5) and re-check netplan file permissions.
Domain doesn't resolveDNS hasn't propagated, or record is wrong. Wait; check with nslookup yourdomain.com. Lower the TTL.
Site works on LAN but not from internetPort 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 bootIt 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 SSHYou disabled passwords before keys worked. Use the Proxmox VM console to fix sshd_config.
Container won't startdocker compose logs <service> almost always tells you exactly why (bad env var, port clash, missing file).
Database data disappeared after restartYou forgot the named volume — data lived inside the ephemeral container. Always mount a volume for DBs.
Disk slowly fills up / server dies overnightv2 Unbounded container logs. Set log rotation in /etc/docker/daemon.json (Section 6); check with docker system df.
Server unreachable after power cutAdd 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.0 except 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