This is the guide for taking one spare x86 machine and turning it into a quiet, backed-up home server that runs the services you actually want: a private Git forge, your own files-and-calendar cloud, home automation. The hypervisor is Proxmox VE, and the shape of the build is deliberate: you build a small backbone once (the install, storage, remote access, a local certificate authority, and backups), and then each service hangs off that backbone as a self-contained section you can take or leave.
So the structure is two halves. Steps 1 through 6 are the backbone, and everyone builds them. Steps 7 through 9 are the services, and you pick whichever you want:
- Gitea, a personal Git forge. The simplest service, and where the per-service container pattern is at its leanest.
- Nextcloud, files, calendar, contacts, and photos on hardware you own. The largest section, because it introduces the most.
- Home Assistant, home automation, and the one service that runs as a full virtual machine instead of a container.
The value here is not “how to click through the Proxmox installer.” That part is easy and well covered. The value is the friction: the things that are wrong or missing in every clean tutorial, the places where the official wizard does half a job, and the handful of errors that lie to you about their own cause. This was built across several real sessions on a real box, and what made it worth writing down is everywhere the plan and the reality diverged.
Two ground rules for reading it. It is hardware-agnostic. The worked example is an old desktop (a Haswell i3, mixed DDR3, a small boot NVMe, a 1TB consumer QLC data drive), and where a hardware fact actually changes a decision it is called out as such, but every step generalizes. It is infrastructure-agnostic. The surrounding network here (a reverse proxy on another box, AdGuard Home for DNS, an existing Tailscale tailnet) is a worked example, not a requirement. The baseline access path for every service is its own native method on its own port; nothing in the backbone assumes you run a particular resolver or proxy.
One honest caveat, the same one this site’s iPXE and MCP-servers guides carry: this is reconstructed from a working, end-to-end build, not yet re-run from a clean bare-metal install following only this text. Every command, path, and value below came from the live build and has been anonymized (tailnet names, IPs, and MACs are illustrative; substitute your own). The gap is a from-scratch reproduction pass, not the result.
The Shape of the Build
Read this before you install anything. It is the map, and it is short, because the whole point of the backbone is that a few decisions get made once and then every service reuses them. If you understand these five ideas up front, every later step is mechanical.
One service per container. Each service lives in its own LXC (a Linux container that shares the host kernel, so the overhead is essentially zero). You get per-service isolation and, more importantly, per-service backup and restore. The one exception is Home Assistant, which upstream only supports as a full VM, and that exception is the entire reason its section exists: it is where you see the VM path next to the container path.
Each guest gets its own MAC, so each gets one DHCP reservation. This trips people: a bridged container does not sit behind the host’s MAC address. Every LXC and VM gets its own auto-generated MAC on the bridge (vmbr0), shows up as its own device on your LAN, and pulls its own DHCP lease. The pattern throughout is: boot a new guest on DHCP, read its IP and MAC, then pin that MAC to that IP with one static-DHCP reservation in your router. One guest, one MAC, one reservation. Static IPs stay stable, and nothing on the network fights over an address.
Remote access is host-only Tailscale, not a subnet router. Tailscale runs on the Proxmox host and nowhere else. A device on your tailnet reaches a service by hitting the host’s tailnet name on the service’s port, and the host forwards that port to the right container with tailscale serve. There is no advertised subnet, no route approval, no accept-routes, and no LAN-subnet awareness pushed onto every tailnet client. This is a deliberate rejection of the subnet-router pattern, and Step 4 explains why at length, because reaching for the subnet router is the single most common over-engineering mistake here.
Internal HTTPS comes from a local CA. Names like nextcloud.lan can never get a public certificate (no public DNS, no certificate authority will sign them), and the tempting alternatives all publish your machine names into public Certificate Transparency logs forever. So the build runs its own small certificate authority with mkcert on the host, issues one multi-name cert per web service, and imports the CA root into your devices once. Browser-trusted HTTPS on internal names, nothing leaked publicly. Set up once in Step 5, used by the services that expose a web UI.
Backups are mandatory and come before the services. Proxmox Backup Server runs in its own container (Step 6) with its datastore on a separate physical disk, and every service you build afterward gets added to one nightly job. It is in the backbone, not optional, for a blunt reason stated again where it matters: if you are certain you do not want backups, you can skip it, and you will regret it.
Hold those five in mind. The recurring villain across the whole build is a class of problem where the symptom points at the wrong layer: a login error that looks like a bad password but is a reverse-proxy stripping a cookie, a “datastore not found” that is actually a permissions problem, a connection that pings but won’t open because of a firewall three machines away. Each one gets called out where it lands. The throughline is that the install is never the hard part; the misdirection is.
Install Proxmox
The install is mostly uneventful, with one wall at the end that locks people out of a working install and sends them debugging the wrong thing. Walk through it deliberately.
Boot media. Proxmox ships no clean network-install image the way Debian does, so this is a USB install. A Ventoy stick is the path (drop the Proxmox ISO onto the stick you already keep for memtest and recovery). The known Ventoy gotcha is that the Proxmox ISO can drop to a busybox shell that cannot find the install medium; on current releases it boots in Ventoy’s normal mode without trouble, and if it ever does not, relaunch the entry in Ventoy’s GRUB2 mode. Disable Secure Boot in firmware for the install to avoid friction.
Mind the version. The current ISO is Proxmox VE 9.x, based on Debian 13 (trixie). A lot of tutorials and copy-paste one-liners still assume 8.x. Treat any version-specific instruction (file paths, default behaviors) as something to verify against what you actually installed, not as gospel. Point-releases also drift under you: the box here reported a different patch version after the first full upgrade than at install, which is normal.
The disk screen. At the target-disk step, select only your boot disk (a small dedicated SSD is ideal). Accept the installer’s defaults for it. The big data drive is configured later, in Step 3; do not select it here. The only real risk on this screen is picking the wrong disk, so slow down and read it.
The NIC dropdown lists ports with no cable in them. The management-network screen’s interface dropdown shows every physical port, including ones that are not connected. Pick the interface that actually has your cable in it. On a board with both an onboard NIC and an add-in card, a useful rule that has nothing to do with driver quality: make the permanent NIC the management interface. An add-in card can be pulled for another machine someday, and the day it leaves, a headless host whose management interface was on that card is unreachable. Onboard ports do not leave. This screen also holds the hostname; a .lan name (for example proxmox.lan) keeps things consistent and never tries to resolve publicly.
The wall: “Connection error 401: no ticket” on first login. This is the one that eats an evening. You finish the install, browse to the UI, and get a 401 with no obvious cause. The trap is reaching the UI through a reverse proxy (or a proxied .lan name): Proxmox’s auth cookie (PVEAuthCookie) does not survive the proxy hop unless the proxy is configured exactly right, so the request reaches Proxmox ticket-less and you get a genuine Proxmox 401, not a proxy error, which is what makes it look like your credentials are wrong.
Important: Do not put the hypervisor UI behind a reverse proxy. Reach it directly at https://<host-ip>:8006, fully accept the self-signed certificate, and log in as root against the “Linux PAM” realm. If you want a friendly name, point DNS for proxmox.lan straight at the host’s IP and use https://proxmox.lan:8006 directly. Save the reverse proxy for the services, never the console. If you insist on proxying it anyway, you must use scheme https to port 8006, enable websockets, serve real TLS with Force-SSL on the proxy, and access it only over https:// so the browser will return the hardened cookie; clock skew on the host will also invalidate the timestamped ticket. The direct-IP path avoids all of it.
Finish the setup. In the web UI under the node’s Updates section, disable the enterprise repositories and add the no-subscription repository, then refresh, upgrade, and reboot. On a single node (no cluster), mask the high-availability services so they stop logging about a cluster that does not exist: systemctl disable --now pve-ha-lrm pve-ha-crm then systemctl mask pve-ha-lrm pve-ha-crm. Leave corosync and pve-cluster alone; the latter is required even on one node.
The subscription nag dialog is optional to remove, and if you do, do it correctly: the line that triggers it moves between releases, so find it on your installed version rather than pasting a one-liner from a forum. Grep proxmoxlib.js for data.status to see which lines are the actual nag checks before patching anything, and if you want it to survive widget-toolkit updates, drive the patch from an APT hook rather than editing the file once by hand.
Storage and the Discard Chain
The data drive becomes one LVM-Thin pool that holds every container and VM disk. Three things here are worth getting right: the pool create has a built-in safety you should understand, “enable TRIM” is not one setting but a chain of three (none of which is where people look first), and a consumer SSD with no power-loss protection makes a managed UPS shutdown load-bearing rather than optional hygiene.
Create the pool. In the web UI, under the node’s Disks then LVM-Thin, create a thinpool on the data drive (name it something like vmdata). It registers automatically as storage for disk images and containers. The dialog will only let you create a pool on a disk that is empty (no partition table, filesystem, or existing LVM). That refusal is the safety: if your target drive has old signatures, the create will not offer it, and the fix is to wipe that disk first (Disks, select it, Wipe Disk), deliberately, after you are sure it is disposable. On a genuinely empty drive the create just works and nothing destructive runs.
The installer also made a small data thin pool on your boot disk. That is expected and unused; leave it alone. Everything you build goes on the data pool.
The discard chain. This data drive in the worked example is consumer QLC, DRAM-less, with no power-loss protection, which makes TRIM matter (a QLC drive that never gets TRIM hits its write cliff sooner and stays there). Here is the trap: there is no storage-level discard toggle for LVM-Thin. People look for a checkbox on the storage and there isn’t one. TRIM is three independent pieces:
issue_discards = 1in/etc/lvm/lvm.confon the host, which TRIMs the SSD when logical volumes are deleted or shrunk. It ships commented out, in a file that is roughly 2600 lines long, so “edit lvm.conf” is not a real instruction. The real procedure isgrep -n issue_discards /etc/lvm/lvm.confto get the line number, thennano +<line> /etc/lvm/lvm.conf, uncomment it, and set it to 1.- Per-guest discard, which is a VM-only disk option. Containers have no discard checkbox at all and
discardis not an allowed container mount option, by design. That is fine here: synchronous online discard is actually undesirable on a DRAM-less QLC drive. For containers, TRIM is entirely piece 3. - Periodic
pct fstrim <ctid>from the host for each container. The host’s ownfstrim.timeronly covers the boot disk’s filesystem, not the thin volumes on the data pool, so enable it for the boot disk (systemctl enable --now fstrim.timer) but know it does not reach your containers.
A small methodology note that bit during this phase and is worth keeping: when you run a verification command after making a change and it shows the desired state, that is the change having worked, not evidence the step was unnecessary. fstrim.timer being active after you enabled it is not proof it was on by default.
A UPS with managed shutdown, because of that drive. This is the one piece of physical setup the build genuinely depends on, and the reason is the drive above: a consumer SSD with no power-loss protection can corrupt its internal mapping, not just an open file, if power is cut mid-write, and a database plus thin-provisioned volumes plus a sudden outage is the classic recipe. So a UPS here is not generic hygiene; it is what the backups exist to avoid having to use. The typical setup, and the one to build, is the simplest: a UPS plugged by USB straight into the Proxmox host, with apcupsd or NUT running locally on the host to watch the battery and trigger a clean shutdown with margin before it is exhausted. The goal is a graceful shutdown within a minute or two of going on battery, not riding out the outage; short runtime is fine. (The worked example here is a variant only because the box’s existing topology already had it: the UPS data cable lands on a small always-on machine that is the apcupsd master, and the Proxmox host joins as a network client over the LAN. That works as long as the network gear between them rides the same UPS so the signal survives the on-battery window. If you do not already have that arrangement, do not build it; plug the UPS into the host and run the daemon locally.)
Remote Access Without a Subnet Router
This is the step where the plan was originally wrong, and the corrected version is so simple it is almost a non-event, which is exactly the lesson. The goal is narrow: reach a few personal services from outside the house. The over-engineered answer is a Tailscale subnet router. The right answer is three commands.
What to do. Install Tailscale on the Proxmox host directly and bring it up as a plain node:
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
No --advertise-routes, no --accept-routes, no flags. Authenticate at the printed URL, confirm the host joined the tailnet with its name and a 100.x address, then disable key expiry for this node in the Tailscale admin console so a 24/7 access point does not silently drop off the tailnet months later. That is the entire host-side setup. Three actions: install, up, key-expiry-off.
Each service then gets one forwarding mapping on the host (not inside the container), added in that service’s own section as you build it. The two shapes you will use:
tailscale serve --bg --tcp=2222 tcp://192.168.1.30:2222
tailscale serve --bg --tcp=8443 tcp://192.168.1.40:443
The first forwards a raw TCP port (here, git-over-SSH) straight through. The second does the same for an HTTPS service on a different external port. Inspect everything with tailscale serve status, and the only place a container’s address ever appears is in these host-side mappings, which is why the per-guest DHCP reservation from Step 1 matters: pin the address and the mapping never drifts.
Important: The tailscale serve command syntax changed across Tailscale versions (the serve and funnel CLI was reworked around version 1.52). Verify the exact syntax against the docs for the version you actually installed before relying on the commands above; do not trust a remembered form.
Why not a subnet router. A subnet router advertises your LAN’s subnet into the tailnet. That sounds convenient and it pushes a surprising amount of complexity onto every tailnet client: each one now has to accept routes, the route has to be approved in the console, and the whole thing becomes aware of and dependent on your LAN’s addressing. When something breaks, the failure surface is scattered across the tailnet (is the route advertised? approved? is the client accepting it? did the container’s IP move?). A host-side serve mapping is one box, one config, one status command. The diagnosability is the entire reason. A subnet router is the right tool for “treat my whole home network as if I’m on it”; it is the wrong tool for “reach four named services,” and reaching for it is the most common way this build gets needlessly hard.
That said, the guide does not pretend its path is the only one. Per-service Tailscale (running it inside each container for a real per-service tailnet identity) is a reasonable choice if you want ACL granularity, at the cost of TUN passthrough and a Tailscale install in every container. A Tailscale-side reverse proxy (Caddy or similar) fronting the serve mappings is a fine later refinement. Plain LAN-only with no tailnet at all is completely valid if you never need off-site access. Pick for your goal; this build picks the simplest thing that meets a narrow one.
A label that lies, noted once for the whole guide. When you run tailscale serve status you will see listeners described as (TLS over TCP, tailnet only). That is Tailscale’s name for the listener mode. It is not a claim that Tailscale is terminating TLS for you. With --tcp the traffic is a raw passthrough; whatever TLS exists is end to end between the client and the service. This label shows up for git-over-SSH (no TLS at all), for HTTPS passthrough, and for plain HTTP, identically. Do not read it as “TLS is handled.” It bites at least three times across this build.
A Local CA with mkcert
Two of the three services expose a web UI you will want to reach over HTTPS without a browser warning, on names that no public certificate authority will ever sign. The clean answer is to run your own small CA on the host with mkcert and trust it on your devices once. Gitea, the third service, deliberately does not need this (its web UI stays LAN-only HTTP and its remote path is SSH), so if Gitea is all you want, you can skip ahead, but Nextcloud and Home Assistant both rely on what you set up here.
Why a local CA and not the obvious alternatives. The reflex is to get a “real” certificate. You cannot, for an internal name, and the workarounds have a hidden cost. Tailscale can issue HTTPS certificates for .ts.net names, and Let’s Encrypt can issue for a domain you own, but both go through publicly logged issuance: the certificate, and therefore the exact hostname, lands in Certificate Transparency logs permanently. For an internal service that is a small but real and irreversible disclosure of your naming and infrastructure. A local CA never submits anything to a public log, so the names in your certificates stay private regardless of what they are. Owning a domain does not change this calculus; it only buys a friendlier name.
Set it up on the host. Generate the CA on the Proxmox host, not inside a container, so the one CA can sign for every present and future service from one place (and your devices import exactly one root, not one per service):
apt install -y mkcert libnss3-tools
mkcert -install
mkcert -CAROOT
libnss3-tools is what lets mkcert install the root into Firefox/Chromium trust stores on client machines; it is harmless on a headless host. mkcert -install initializes the CA, and -CAROOT prints where the root lives (rootCA.pem is the file you will distribute).
Issue a cert with every name a service answers to. mkcert takes all the names and IPs in one command and sorts DNS names from IP addresses into the right certificate fields automatically. For a service reachable on a LAN name, its LAN IP, and a tailnet name and IP, that is one line:
mkcert nextcloud.lan 192.168.1.40 proxmox.your-tailnet.ts.net 100.x.y.z
That writes a cert and key (named after the first entry) good for years, with all the X.509 subject-alternative-name plumbing that doing this by hand with openssl makes tedious. You push the resulting cert and key into the relevant container later, in that service’s section.
Trust the root on your devices. This is a one-time step per device, with one footgun. Copy rootCA.pem to the device and import it:
- Windows: save it as a
.crt, run the certificate import wizard, choose Local Machine, and place it in “Trusted Root Certification Authorities.” Chromium-family browsers (Chrome, Edge) use this Windows store and trust it immediately. - Firefox uses its own separate trust store. This is the footgun: people import to Windows, watch Chrome work, and wonder why Firefox still warns. Import the same root again in Firefox under Settings, Privacy and Security, Certificates, View Certificates, Authorities, Import.
- iOS: install the root as a configuration profile (Settings, General, VPN and Device Management), then separately enable full trust under Certificate Trust Settings; iOS makes you do both.
- Android: Settings, Security, “Encryption and credentials,” “Install a certificate,” “CA certificate.”
Once the root is trusted, every cert you mint from this CA validates with no warning, on every name you put in it.
Backups First, in Their Own Container
Important: If you are absolutely certain you do not want backups, skip this section. You will regret it. Every service you are about to build benefits from this, which is why it is in the backbone and not optional.
Proxmox Backup Server (PBS) does deduplicating, verifiable, incremental backups, and it runs perfectly well in its own container on the same host it backs up. This is also the first container you build, so this section doubles as the walkthrough of the container-creation pattern that Gitea and Nextcloud will both reuse. Read the container mechanics here once.
The honest scope, stated up front. PBS on the same host protects against the things that actually happen most: an accidental delete, a botched config, a bad upgrade, a “restore me to last Tuesday.” Putting its datastore on a separate physical disk (below) also covers a single drive dying. It does not protect against the host dying, the box being stolen, ransomware on the host, or the building burning down. That requires an off-site copy, covered as a “doing this right” note at the end. This is an honest local-only version one. Build it, then know what it does and does not cover.
Prepare a separate disk for the datastore. Use a dedicated external or USB SSD so the backups do not share the drive they are protecting. Inspect it before you touch it (a Windows-initialized disk shows a tiny ~16 MB MSR partition next to its main one; that is normal, not a problem to panic about), confirm it is disposable, then wipe, partition, and format it. One live gotcha: after partitioning, the tool to re-read the partition table is often given as partprobe, which lives in the parted package that is not installed on a minimal Proxmox host. Reach for partx -u /dev/sdX instead (it is part of util-linux, always present). Format with mkfs.ext4 -L pbs-store -m 0 /dev/sdX1 (the -m 0 skips the 5% reserved-for-root space, wasted on a single-purpose backup target); it returns almost instantly on a large drive because ext4 initializes inode tables lazily in the background, which looks too fast but is correct.
Mount it on the host by stable identifier, never by /dev/sdX, because USB devices renumber across reboots. Add an /etc/fstab line keyed on the /dev/disk/by-id/usb-... symlink with options defaults,nofail,noatime,x-systemd.device-timeout=10. The nofail plus device-timeout pair is what keeps the host from hanging at boot if the drive is ever unplugged.
Create the container (the reusable pattern). In the web UI, create a CT: unprivileged, the Debian 13 standard template, modest resources (2 GB RAM, 2 cores, an 8 GB root disk on your data pool, because the backup data lives on the separate disk, not in the container). Leave it on DHCP for first boot. Uncheck “Start after created” here specifically, because PBS needs its datastore mount present before it first starts. The pattern points that recur for every container:
- It boots on DHCP, gets its own MAC, and you pin it. Read the lease and MAC, add one static-DHCP reservation in your router. (Step 1’s per-guest-MAC point, in practice.)
- The minimal Debian template is missing things you expect. No
sudo, nognupgout of the box. Install what a given step needs, or userunuser -u <user> -- <cmd>in place ofsudo -u. - The template sets a locale it did not generate, so commands emit harmless “Setting locale failed” warnings until you fix it once:
apt install -y locales, uncommenten_US.UTF-8 UTF-8in/etc/locale.gen,locale-gen,update-locale LANG=en_US.UTF-8, then reconnect the shell. Set the timezone too (timedatectl set-timezone ...). onbootdefaults to 0. A freshly created container does not autostart when the host reboots until you set it (pct set <ctid> -onboot 1). Easy to miss, and you find out the hard way after a power blip.
Bind-mount the datastore, and get the ownership right before first start. Add the host’s mounted datastore path into the container as a bind mount from the host CLI: pct set <ctid> -mp0 /mnt/pbs-store,mp=/datastore. The subtlety with an unprivileged container is UID mapping: the container’s users are shifted by 100000 on the host. PBS runs as the backup user (UID 34 inside), which is host UID 100034. So before first start, chown -R 100034:100034 /mnt/pbs-store on the host. Verify with ls -ldn (the -n is the point: a numeric listing, because host UID 100034 has no name). After PBS installs, confirm id backup inside the container actually returns 34, and if your distribution assigned a different UID, redo the chown to match. It is a chain (host UID equals the 100000 shift plus the in-container UID), not a guess.
Install PBS. Add the PBS no-subscription repository. On current Debian this is the modern deb822 format at /etc/apt/sources.list.d/proxmox.sources with the keyring at /usr/share/keyrings/proxmox-archive-keyring.gpg. Note the keyring filename uses the -archive-keyring- form, not the older proxmox-release- name; verify the current path against the PBS docs rather than trusting an older write-up. Then apt update && apt install -y proxmox-backup-server. Both services come up and the web UI is on port 8007.
Create the datastore, then hit the trap. In the PBS UI, create a datastore named (for example) store1 backed by /datastore. Then, to let Proxmox VE use it, you create an API token in PBS (use a dedicated token, not root) and add PBS as a storage target on the VE side. Here is the headline trap:
Warning: When you add the PBS storage on the Proxmox VE side, you may get the error cannot find a datastore 'store1', and it is almost certainly lying about the cause. PBS deliberately collapses “this datastore does not exist” and “your token has no permission to see it” into the same error, so that an unauthorized token cannot even enumerate datastores. A freshly created API token has, by default, zero permissions (when a token’s privsep is unset, it is treated as ON, which means it inherits nothing). The datastore is fine; the token cannot see it. Fix it by granting the token rights on the datastore: proxmox-backup-manager acl update /datastore/store1 DatastoreAdmin --auth-id '<user>@pam!<tokenname>'. If proxmox-backup-manager datastore list shows the datastore but VE says it cannot find it, it is always a permissions problem, never a typo.
With the token authorized, add the storage on VE (Datacenter, Storage, Add, Proxmox Backup Server) using the PBS container’s IP, the token as the username in user@realm!tokenname form, the token secret as the password, the datastore name, and the fingerprint reported by proxmox-backup-manager cert info on the PBS side.
Create the backup job. Datacenter, Backup, Add: target the PBS storage, schedule it (for example 02:00), select the containers to back up, mode Snapshot. Do not back up the PBS container itself; backing up the backup target to itself is pointless. Set retention on the job (a sensible start: keep-last 7, keep-daily 14, keep-weekly 8, keep-monthly 12), not on the datastore, so you can vary it per service later. Run it once manually and confirm it completes and the datastore size is plausible.
A couple of small things you will see and should not worry about: apt full-upgrade pulls in postfix as a dependency (PBS routes its job notifications through local mail; harmless), and a missing-PTR inverse host lookup failed line from nc when you test connectivity to the PBS IP is just the LAN address having no reverse DNS.
Test a restore, because a backup you have never restored is a hope, not a backup. Restore a backed-up container to a new container ID, boot it, confirm the service comes up and the data is intact, then delete the copy. This is the only step that proves the backup is real, and it exposes the second trap:
Warning: pct restore preserves the original container’s MAC address. The restored copy comes up with the same MAC, your router’s static reservation hands it the same IP, and now two containers are fighting over one address on the network. Two clean fixes: either force a fresh MAC on the restored copy by resetting its net device without an hwaddr (so Proxmox auto-generates one on next start), or briefly stop the original, start the restored copy to verify it at the canonical IP, then stop the copy and restart the original. The first is less disruptive; the second tests against the exact original identity.
Turn on Verify jobs. In PBS, schedule a weekly Verify job on the datastore. Verify re-reads every chunk and recomputes its checksum against the index; it is the whole reason to use PBS over plain dumps, because it catches silent bit-rot on the backup drive before a restore needs that data and finds it corrupt.
Doing this right (the off-site step you should plan for). Everything above is local-only. The real end state is 3-2-1: this local PBS plus an off-site copy. The clean ways to get there are PBS-to-PBS remote sync to a second PBS instance somewhere else (the cheapest being a small VPS), or pushing chunks to S3-compatible object storage with proxmox-backup-client. That is a deliberate next step, not part of version one, but you should not pretend the local copy is the whole job. It is not.
Gitea, the Simplest Service
Gitea is a single-binary Git forge. It is the leanest service in the build: one binary, a SQLite database, the container pattern you already learned in Step 6, and exactly one non-obvious wizard footgun that silently breaks remote access until you fix it. The reason to run your own is ownership: depending on a single hosted platform means its outages and policy changes are yours to absorb on its schedule, and a personal forge is a cheap hedge against that, not a hobby. (GitLab is a heavier platform than single-user scale needs; Gitea is the right tier.)
Container and prerequisites. Create an unprivileged Debian 13 LXC exactly as in Step 6 (2 GB RAM, 2 cores, a 20 GB disk on the data pool, DHCP then a reservation, locale and timezone fix, onboot 1). Install the basics the minimal template lacks: apt install -y wget gnupg git, then create the system user the official way:
adduser --system --shell /bin/bash --gecos 'Git Version Control' --group --disabled-password --home /home/git git
Install the binary and verify it. Gitea is not pinned by upstream, so check the current stable version at the time you build. Download the linux-amd64 binary, and actually verify its GPG signature against Gitea’s release key rather than skipping it:
gpg --keyserver keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2
A good signature reads “Good signature from Teabot.” Install the binary to /usr/local/bin/gitea, then create the directory layout the docs specify: /var/lib/gitea/{custom,data,log} owned git:git mode 750, and /etc/gitea owned root:git mode 770 (you tighten /etc/gitea to 750 and app.ini to 640 after the first run).
The systemd unit needs zero edits. Pull the upstream sample unit from Gitea’s matching release branch (contrib/systemd/gitea.service). For the SQLite-plus-default-port path, it works unmodified: every database Wants/After line and every privileged-port capability line is already commented out, and it already points at the right binary, directories, and user. Drop it into /etc/systemd/system/gitea.service, systemctl daemon-reload, systemctl enable --now gitea. It is also correct to start the service before app.ini exists; Gitea serves its install wizard on port 3000 and writes app.ini itself because /etc/gitea is group-writable by git.
Run the wizard, on the LAN. Browse to http://<gitea-lxc-ip>:3000, and in the wizard choose SQLite, set “Run As Username” to git, set the SSH server port to 2222, leave the HTTP port at 3000 (the base URL auto-fills from the address you used, which is correct), create the admin account in the wizard rather than by first-registration, and tick “Disable Self-Registration.” Plain HTTP on the LAN is fine here; this web UI is never exposed over the tailnet, so there is no certificate to manage. The “Not Secure” label on a LAN-only admin UI is acceptable.
Warning: The wizard’s “SSH Server Port: 2222” sets SSH_PORT in app.ini but does not set START_SSH_SERVER = true. The default is false, which tells Gitea to expect the system’s own sshd on that port, so with the wizard alone nothing listens on 2222 inside the container and every clone over SSH returns “Connection refused” even though the install looks finished. Add START_SSH_SERVER = true to the [server] section of /etc/gitea/app.ini and systemctl restart gitea. Then ss -ltnp | grep 2222 shows Gitea listening. This is the single highest-value fix in the section; the entire SSH and tailnet path is silently dead without it.
Remote access: one mapping, SSH only. Add the host-side serve mapping from Step 4 for git-over-SSH, and nothing for the web (the web is LAN-only by design):
tailscale serve --bg --tcp=2222 tcp://192.168.1.30:2222
Now clones work locally at the container’s LAN IP and remotely through the host’s tailnet name on port 2222, with no subnet router in the path. The git auth model is SSH keys first (generate a keypair on each client, paste the public key into Gitea under User Settings, SSH Keys, then git clone ssh://git@<host>:2222/<user>/<repo>.git), with a Personal Access Token as a secondary path only if you specifically want HTTPS git URLs for some tool. OAuth is not used.
Tip: If a tailnet client can tailscale ping the host but a git clone or ssh -p 2222 hangs at connect, suspect that client’s own outbound firewall, not Tailscale and not Gitea. A hardened box with a default-deny outbound policy will let Tailscale’s own UDP through (so ping works) while dropping the application TCP, which looks exactly like an MTU problem but is not. The fix is on that client: allow outbound to the Tailscale CGNAT range 100.64.0.0/10 (and fd7a:115c:a1e0::/48 for IPv6), or scope the allow to the tailscale0 interface. See this site’s VPS hardening guide for where that rule belongs.
One latent thing to note and clear: the container’s network device may have firewall=1 set. It is inert today because the datacenter firewall is off, but if you ever turn the Proxmox firewall on, that interface defaults to blocking. Either turn it off now (pct set <ctid> -net0 ...,firewall=0) or remember it is there. Finally, add this container to the PBS backup job from Step 6.
Nextcloud, the Data Service
Nextcloud is the largest section because it introduces the most: a full application stack (web server, database, cache, PHP), a two-volume container layout that becomes the template for any data-bearing service, the multi-name certificate from Step 5 wired into real HTTPS, and a post-install warnings sweep that every fresh install needs. The reason to run it is straightforward and also a little bigger than convenience: with a hosted provider, an automated policy decision can take not just a file but the whole account and everything in it, often with little recourse. Self-hosting is the hedge. Your data, on hardware you control.
The two-volume container layout. This is the pattern for any service with real data, and it is better than the single big disk you might reach for. Create the container with a small root disk for the OS (16 GB) plus a separate data volume mounted inside it:
pct set <ctid> -mp0 vmdata:100,mp=/var/nextcloud/data,backup=1
That allocates a fresh 100 GB volume (thin-provisioned, so it does not actually consume 100 GB until written), formats it, and mounts it inside the container. The backup=1 is what makes PBS pick it up. Two volumes means you can resize the data independently with pct resize, swap storage backends without rebuilding, and keep a clean mental separation of OS from data. Otherwise it is the standard container build from Step 6 (unprivileged, Debian 13, DHCP plus reservation, locale, timezone, onboot 1, 4 GB RAM, 2 cores).
Tip: A freshly formatted data volume contains a lost+found directory at its root, and Nextcloud refuses to use a data directory that is not empty. Do not fight it: put the data one level down, in a subdirectory like /var/nextcloud/data/nc (pre-create it, chown www-data:www-data, mode 0750), and leave lost+found alone at the mount root.
Install the stack. Two apt install lines (split for safe pasting), using Apache with mod_php rather than PHP-FPM (Nextcloud’s own example uses mod_php, it is one fewer service, and at one-to-three users the performance difference does not exist):
apt install -y apache2 mariadb-server redis-server libapache2-mod-php
apt install -y php-gd php-mysql php-curl php-mbstring php-intl php-gmp php-xml php-imagick php-zip php-bcmath php-bz2 php-apcu php-redis imagemagick
The minimal template has no sudo, so the occ commands below use runuser -u www-data -- php /var/www/nextcloud/occ .... Install gnupg before verifying the Nextcloud download signature.
Tune MariaDB and use a Redis socket. Put the database tuning in its own file (/etc/mysql/mariadb.conf.d/99-nextcloud.cnf) so package upgrades do not clobber it: utf8mb4 character set and collation, transaction_isolation = READ-COMMITTED, binlog_format = ROW, innodb_buffer_pool_size = 1G, innodb_file_per_table = 1, and the read-buffer tunings from Nextcloud’s docs. (On MariaDB 11.x, pinning the buffer-pool min and max alongside the size locks it rather than letting it auto-resize.) You can skip mysql_secure_installation: Debian’s MariaDB package already removes anonymous users, blocks remote root via socket auth, ships no test database, and listens on localhost only.
Configure Redis as a Unix socket, not TCP. Debian ships the socket lines commented in redis.conf; enable them, set unixsocketperm 770 (770, not 700, so it is group-readable), add www-data to the redis group, and restart. Confirm with redis-cli -s /run/redis/redis-server.sock ping returning PONG. Create the Nextcloud database and a user with a password from openssl rand -base64 24 (saved separately, though Nextcloud stores it in plaintext in its config after install anyway).
Get Nextcloud onto disk and serving over HTTP. Download the current Nextcloud server release, verify its GPG signature against Nextcloud’s published key, and extract it into /var/www/ so the application lives at /var/www/nextcloud, then chown -R www-data:www-data /var/www/nextcloud. Create a minimal Apache site whose DocumentRoot points there with AllowOverride All, enable the modules this and the later HTTPS vhost both need (a2enmod ssl rewrite headers), enable the site, and reload. The install wizard is now reachable over plain HTTP on the LAN.
Run the install. Browse to the container over HTTP for the wizard (HTTPS comes next). Set the admin account, set the data folder to your /var/nextcloud/data/nc subdirectory, choose MySQL/MariaDB (not the SQLite default), enter the database credentials, and uncheck “Install recommended apps” for a faster, cleaner first init. Then tune via occ: set APCu as the local memcache and Redis (over the socket, with redis.port = 0 to signal socket mode) as the locking and distributed cache; switch background jobs from AJAX to system cron (occ background:cron plus a /etc/cron.d/nextcloud entry running every 5 minutes as www-data); and add your access names to trusted_domains. Note that Nextcloud matches trusted_domains on hostname without port, so one entry for your tailnet name covers both 443 and the alternate external port.
Wire up real HTTPS with the Step 5 cert. Issue the multi-name cert (LAN name, LAN IP, tailnet name, tailnet IP) with mkcert on the host as in Step 5, then push it into the container with Proxmox’s native file transfer (no scp needed): pct push <ctid> <host-cert> /etc/ssl/certs/nextcloud.pem and the key to /etc/ssl/private/. Replace the Apache site with a two-vhost config: a port-80 vhost that does nothing but redirect to HTTPS preserving the requested hostname (RewriteRule ^/?(.*) https://%{HTTP_HOST}/$1 [R=301,L], so the redirect works for both the LAN name and the tailnet name), and a port-443 vhost that serves Nextcloud with the mkcert cert, AllowOverride All for Nextcloud’s bundled rewrites, and an HSTS header. The ssl, rewrite, and headers modules are already enabled from the HTTP setup above; apache2ctl configtest should report Syntax OK, then reload. The full vhost is short; the one thing that matters is the %{HTTP_HOST} in the redirect, because a hardcoded redirect target would break whichever path you did not hardcode.
Then add the tailnet mapping on the host (note the external port differs from the internal one):
tailscale serve --bg --tcp=8443 tcp://192.168.1.40:443
LAN clients reach https://nextcloud.lan/ (via a DNS rewrite or hosts entry to the container IP); tailnet clients reach https://proxmox.your-tailnet.ts.net:8443/. The same cert validates on both because both names are in it.
PBS backs up both volumes automatically. Add this container to the existing backup job and run it. The log will show both logical volumes snapshotted and backed up atomically, because PBS handles a multi-volume container natively once each extra mountpoint has backup=1. You do not configure anything special; the data volume is not being missed.
The post-install warnings sweep. Every fresh Nextcloud surfaces the same handful of admin-panel warnings regardless of host. Clear them once:
| Warning | Fix |
|---|---|
| PHP memory limit (a red error) | Debian defaults to 128 MB; Nextcloud’s updater needs 512 MB. Set memory_limit = 512M in a 99-nextcloud.ini for both the apache2 and cli PHP SAPIs. Highest-value of the five: below this the updater silently does not work. |
| opcache interned strings buffer | Set opcache.interned_strings_buffer = 16 (default 8) in the same file. |
| Maintenance window not set | occ config:system:set maintenance_window_start --type=integer --value=<UTC hour> (pick a quiet hour for your timezone). |
| Mimetype migrations | occ maintenance:repair --include-expensive once. |
| Default phone region | occ config:system:set default_phone_region --value=<ISO 3166-1 code>. |
Reload Apache after the PHP changes. A lingering “errors in the log” item about the old 128 MB limit is just the pre-fix error not yet aged out of the panel’s window; it clears on its own within about a day, or force a log rotation. (A UI quirk worth knowing: Nextcloud’s admin-panel error timestamps sometimes render the wrong date; the raw log file has the correct ISO timestamp, so trust the file if anything looks off.) The remaining warnings you can leave: SMTP and 2FA are reader-specific preferences (point at Nextcloud’s docs, do not prescribe), and the AppAPI deploy daemon and “configuration server ID” warnings are permanent and ignorable on a single-server install that is not using Docker-based external apps.
Home Assistant, as a VM
Home Assistant is the one service that does not run as a container, and that is exactly why it is here: it is where the build shows the VM path next to the LXC path. Upstream supports it as HAOS, the purpose-built Home Assistant Operating System, delivered as a VM image, and that is the path with the long-term support. The reason to run it, beyond that it is excellent on its own, is a privacy one: presence, cameras, sensors, and voice are about the most intimate data a home produces, and keeping it on hardware you own rather than a vendor cloud that can change terms, get breached, or sunset the product is the whole point.
The asset trap. This wastes the most time, so get it first. The generic x86-64 HAOS build ships only a raw .img.xz (for bare metal) and has no qcow2. The qcow2 image you want for Proxmox is the OVA build, named haos_ova-<version>.qcow2.xz. Despite “ova” in the name, that is the qcow2 for KVM/Proxmox, not a VMware-only artifact. Guessing a haos_generic-x86-64-*.qcow2.xz URL returns a 404. Pull the exact asset name from the HAOS releases page rather than constructing the URL, download it to the host, and unxz it to a qcow2.
Create the VM, and UEFI is mandatory. HAOS will not boot on the default SeaBIOS; it requires the q35 machine type and OVMF (UEFI) firmware. The VM ID 200 below is an example (the same convention containers used with <ctid>); use any free ID, and use the same one in all three commands:
qm create 200 --name haos --machine q35 --bios ovmf --cpu host --cores 2 --memory 4096 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-single --efidisk0 vmdata:0,efitype=4m,pre-enrolled-keys=0 --ostype l26 --onboot 1 --agent enabled=1
pre-enrolled-keys=0 is the CLI form of leaving the GUI “Pre-Enroll keys” box unchecked (no Secure Boot). The efidisk0 vmdata:0 size after the colon is ignored; :0 just allocates the standard small EFI vars disk. --agent enabled=1 matters because HAOS runs the QEMU guest agent, which gives you IP reporting and clean shutdown.
Import the disk in one shot, and discard is finally available. Containers never had a discard option (Step 3); a VM disk does:
qm set 200 --scsi0 vmdata:0,import-from=/root/haos_ova-<version>.qcow2,discard=on,ssd=1
qm set 200 --boot order=scsi0
import-from imports and attaches in one command (it replaces the older two-step import-then-attach). The boot-order flip is required: with no disk at create time the VM defaults to order=net0 and will try to PXE boot until you point it at scsi0.
First boot and finding it. It boots straight into UEFI with no shell detour, confirming the firmware settings. HAOS then pulls its core container on first boot (it needs internet, a few hundred MB), so port 8123 shows “Preparing Home Assistant” for a few minutes; that is not a hang. Once the guest agent is up (about a minute), get the IP with qm guest cmd 200 network-get-interfaces. The VM has its own MAC on the bridge, same per-guest pattern as the containers, so pin it with a reservation.
Backups: same job, live snapshot. Add the VM to the same PBS job as the containers (vzdump 200 --storage pbs --mode snapshot, then add it to the recurring job). Snapshot mode on a VM is a live QEMU snapshot with an agent-driven filesystem freeze, a different mechanism from the container path but the same one job. A large thin-provisioned disk costs almost nothing to back up when it is mostly empty: a 32 GB disk that is 80% zero data stores only a few GB after PBS dedups the empty space.
The real lesson: one service’s HSTS can break another on a shared name. This is the most valuable finding in the whole build, because it is a non-obvious consequence of the shared-name tailscale serve model the guide uses everywhere. The first HA mapping is a plain TCP passthrough to HA’s HTTP UI:
tailscale serve --bg --tcp=8123 tcp://192.168.1.50:8123
(And here is the third and clearest sighting of that misleading (TLS over TCP, tailnet only) label from Step 4: HA is plain HTTP with zero TLS in the path, yet the label appears. It is a listener-mode string, not a TLS claim. Believe it now.)
Then the tailnet URL refused to load, force-upgraded to HTTPS by the browser. The cause:
Warning: HSTS is scoped to the hostname, not the port. Nextcloud (Step 8) sends an HSTS header on the shared tailnet name. Once your browser has seen HSTS at https://proxmox.your-tailnet.ts.net:8443 (Nextcloud), it pins the entire hostname to HTTPS, including :8123 (Home Assistant). So http://...:8123 gets auto-upgraded to https://, and an HTTP-only HA refuses the handshake. Routing multiple services through one shared tailnet name differentiated only by port means one HTTPS service’s HSTS poisons plain-HTTP access to every sibling on that name. You can confirm it by hitting the tailnet IP instead of the name (HSTS does not apply to bare IPs); that still loads over HTTP.
The fix is to give HA real HTTPS too, with a cert from the Step 5 CA. Mint a fresh cert for HA’s names (tailnet name, tailnet IP, LAN IP) on the host, place it in HA’s /config directory, and point HA at it:
mkcert -cert-file /root/haos.pem -key-file /root/haos-key.pem proxmox.your-tailnet.ts.net 100.x.y.z 192.168.1.50
Then add the certificate under the http: block in configuration.yaml:
http:
ssl_certificate: /config/haos.pem
ssl_key: /config/haos-key.pem
and do a full restart (an HTTP config change needs a restart, not a reload). The existing --tcp=8123 passthrough now carries end-to-end TLS, HSTS is satisfied, and it validates on every path because all the names are in the cert.
Tip: Put the cert in /config, not the conventional /ssl. HA’s File editor app writes to /config by default, so using it avoids toggling the editor’s “Enforce basepath” option, and HA reads certs from /config fine. It is one fewer step for the same result.
A few HAOS UI notes, because older tutorials are stale on all of them: “Add-ons” is now called “Apps” (under Settings, Apps), the “Install app” button is in the bottom-right corner, and the File editor’s new-file dialog is confirmed with its OK button, not the Enter key. None are hard once you know; all are confusing if you are following a guide written before the rename.
What’s Next
In rough order of how likely you are to want it:
- Use the services, and migrate data into them organically. This is not a build phase. Push your repositories into Gitea as you touch each machine, install Nextcloud’s desktop and mobile clients and turn on selective sync, and start using Home Assistant. One performance note for big initial Nextcloud syncs on a consumer QLC drive: expect throughput to drop to the drive’s native speed partway through a large transfer as its fast cache exhausts. It finishes; it is just slower than the spec sheet, and the sync did not break.
- The off-site backup. The single most important follow-up. Everything in Step 6 is local-only, and 3-2-1 needs an off-site copy: PBS-to-PBS sync to a second instance, or
proxmox-backup-clientto object storage. Plan it; do not leave the local copy pretending to be the whole job. - Local DNS and a reverse proxy, as their own topic. This build deliberately does not prescribe a DNS or proxy stack; the friendly-name path here is a local CA plus whatever resolver you already run. A proper AdGuard Home / Unbound / reverse-proxy setup is its own ecosystem and its own guide.
- A media server, the Plex-replacement path. Jellyfin plus a minimal organizer for content you already own is a separate build, and worth doing with a deliberately narrow stance (legal libraries, no transcoding) that sidesteps the GPU-passthrough thicket every other media guide gets stuck in.
- Dashboards and monitoring. Watching the services (uptime monitoring, a start page, metrics) is a different layer from running them, and a reasonable companion build once the stack is up.
The throughline, if you keep one thing: build the backbone once and the services are mechanical, because the hard parts were never the services. They were the misdirections, the 401 that blamed your password, the wizard that left SSH dead, the backup error that hid a permissions problem, the restore that cloned a MAC, the HSTS header that reached across a shared name to break a service it had nothing to do with. Get the five ideas in Step 1 right, expect the symptom to point at the wrong layer, and the rest follows.
Toolkit Reference
The core stack, and the few spots where a second model or a careful prompt genuinely saves an afternoon rather than a minute.
The Stack
- Proxmox VE
- The hypervisor and web UI. Runs every service as an LXC except Home Assistant, which runs as a VM. Do not reverse-proxy its console; reach it directly at
:8006. - Tailscale
- The remote-access plane, host-only. Per-service
tailscale serveport-forwards, no subnet router. Verify the serve syntax against your installed version; ignore the misleading(TLS over TCP)listener label. - mkcert
- A local CA on the host so internal names get browser-trusted HTTPS with no public Certificate Transparency disclosure. One CA, one multi-name cert per web service, one root import per device (Firefox keeps its own trust store).
- Proxmox Backup Server
- Deduplicating, verifiable backups in their own container, datastore on a separate disk. Mandatory backbone, not optional. Local-only until you add off-site sync.
- Gitea
- Single-binary Git forge, SQLite, the leanest LXC pattern. The one required edit the wizard omits is
START_SSH_SERVER = true. - Nextcloud
- Files, calendar, contacts, photos. Apache + MariaDB + Redis + PHP, the two-volume container pattern, a multi-name cert, and a five-item post-install warnings sweep.
- Home Assistant
- Home automation as a HAOS VM (the OVA-named qcow2, q35 + OVMF mandatory). The HSTS-across-a-shared-name lesson lives here.
Where a Second Look Earns Its Keep
- Errors that lie about their own cause
- The 401-no-ticket (a proxy stripping a cookie, not bad credentials), the PBS "cannot find datastore" (a permissions problem, not a missing datastore), and the connect-hang that pings fine (a client firewall, not the service). When an error points at one layer, the highest-value question is whether the symptom belongs to a different one.
- The HSTS cross-contamination
- One service's HSTS header breaking a sibling on a shared tailnet name is non-obvious enough that describing only the symptom ("HA won't load over HTTP on the tailnet") sends most troubleshooting at HA. Framing it as "what could force HTTPS on a host I never configured for it" is what surfaces host-scoped HSTS.
- Version-sensitive syntax, pinned to your install
- The
tailscale serveCLI, the PBS repo and keyring paths, and the current Gitea version all drift. The reliable move is to verify each against the docs for the exact version you installed rather than trusting a remembered or copied form.