Skip to main content

Personal VPS Fundamentals

These notes describe a safe way to set up a personal server: verify the machine’s identity, establish remote access without risking a lockout, install the container runtime and shared ingress, then use RSSHub to verify the first real application deployment path. Phase 3 remains a shadow deployment: the new RSSHub instance is usable, but the existing feed consumer still uses local RSSHub; no production consumer has been cut over.

If you are still choosing where to run an application, compare this self-managed server approach with Cloudflare Workers and Google Cloud's Cloud Run tutorial. Those guides cover managed execution, permissions, storage, and billing; this series focuses on operating the Linux host itself.

To follow a hostname through ports and HTTPS to the application, start with the network request lifecycle.

Start with the big picture: two doors​

Think of the VPS as a machine room placed on the Internet. It now has two very different ways in:

  1. The management door is for me: WSL2 first takes the private Tailscale route, then OpenSSH checks my key and opens a terminal.
  2. The website door is for Internet visitors: every public HTTP/HTTPS request reaches Caddy first, and Caddy decides which website or API should receive it.

Uptime Kuma is an administration tool, not a public website. It has no public door; I create a temporary SSH tunnel when I need to view it.

[Me: WSL2]
`-- private Tailscale road
`-- locked OpenSSH management door
`-- [Ubuntu VPS]

[Internet visitor]
`-- TCP 80/443
`-- Caddy public reception desk
|-- infrastructure health check
`-- infra-edge corridor -> RSSHub:1200

[Inside the VPS]
|-- Docker runs Caddy, Uptime Kuma, and RSSHub separately
|-- UFW controls which host ports may receive traffic
|-- Uptime Kuma waits only on 127.0.0.1:3001 for an SSH tunnel
`-- RSSHub port 1200 is reachable only inside the Docker network; not published on the host

[Existing local environment]
`-- feed consumer -> local RSSHub (production path and fallback)

Three layers are often confused:

  1. Network reachability determines whether packets can reach the VPS.
  2. OpenSSH provides an encrypted session and verifies the server's identity.
  3. SSH public-key authentication proves that the person signing in holds the matching private key.

Tailscale supplies only the private network path in this setup. Ubuntu's OpenSSH still listens on port 22 and verifies user public keys. I have not enabled the separate Tailscale SSH product feature.

Vocabulary​

TermMeaning here
VPSA virtual Linux machine supplied by a cloud provider
Public IPAn address routable from the Internet
Tailscale IPA private 100.x.x.x address reachable only within the same tailnet
PortA network service entry point, such as TCP 22 for SSH
daemonA long-running background service process such as sshd, tailscaled, or dockerd
systemdUbuntu's system for managing services, startup state, and logs
UFWUbuntu's host-firewall frontend
imageA read-only template used to create containers
containerAn isolated process created from an image, together with its runtime state
ComposeA Docker plugin describing containers, networks, and volumes in YAML

Installed or enabled tools​

The components below were actually installed, enabled, or explicitly configured during Phases 1–3. Start with the analogy, then attach the technical name to it.

Tools that let me sign in safely​

ToolThink of it asProblem it solves
OpenSSH ServerA locked management door that accepts the right keyConfirms that the client holds the private key matching a registered public key, then provides a remote command line; the private key is never sent to the server
TailscaleA private road connecting only my devicesLets WSL2 find the VPS through a private address instead of exposing management to the whole Internet
UFWThe visitor list at the gateDenies incoming traffic by default and allows public Web ports plus SSH on tailscale0; on Linux, Tailscale defaults to on mode, whose early firewall rules accept traffic arriving through tailscale0

Tailscale and OpenSSH are different: Tailscale builds the road to the server; OpenSSH checks the key when I reach the door.

Tools that run services​

ToolThink of it asProblem it solves
Docker EngineA building manager for isolated roomsRuns each service in a separate container and manages images, networks, and persistent mounts
Docker ComposeThe furnishing and startup instructions for each roomRecords the image, data path, ports, and restart policy in one YAML file
Docker BuildxA workbench for making new room templatesBuilds custom Docker images when an existing image is not enough; normal Caddy and Kuma operation does not require it

The three current configuration files are /srv/caddy/compose.yaml, /srv/uptime-kuma/compose.yaml, and /srv/rsshub/compose.yaml. They are independent, so restarting RSSHub does not restart ingress or monitoring.

Tools for public ingress and monitoring​

ToolThink of it asProblem it solves
CaddyThe public reception desk for websitesReceives all public traffic on 80/443, manages HTTPS certificates and HTTP redirects, then sends each request to the right service
Uptime KumaA watchkeeper with a status boardRegularly checks health URLs and records whether services are online; its management interface remains private
RSSHubA translation desk that turns site updates into feedsRuns real RSS routes and uses ACCESS_KEY to stop strangers from consuming the private instance

Caddy and RSSHub join the Docker network named infra-edge. Think of it as an internal corridor from reception to the web service: Caddy finds RSSHub through the alias rsshub-prod, without publishing RSSHub’s port 1200 on the host or the Internet. Kuma stays outside this proxy path and remains available only through its SSH tunnel.

When will I use each one?​

  • Signing in to the VPS: connect Tailscale, then run ssh; Tailscale, UFW, and OpenSSH work together behind the scenes.
  • Starting, stopping, or inspecting a service: use docker compose; do not edit Docker's internal files directly.
  • Opening Kuma: create the SSH local forward, then browse to http://127.0.0.1:3001; do not make port 3001 public.
  • Checking the public endpoint: open the HTTPS /healthz URL served by Caddy.
  • Using RSSHub: a trusted consumer calls the HTTPS domain with the ACCESS_KEY; the key moves only from the protected VPS .env file to trusted consumers, never into public notes or monitoring URLs.
  • Buildx: use it only when building a custom image; routine operation rarely needs it.

Four boundaries to remember​

  • Only public TCP 80/443 enters, and it reaches Caddy first.
  • SSH travels only through the Tailscale private network; public SSH stays blocked.
  • Kuma listens only on the VPS's own 127.0.0.1:3001 and is viewed through an SSH tunnel.
  • RSSHub port 1200 has no host publication. Caddy reaches it through infra-edge; the Docker host and other containers on that network can also reach its container address.

“UFW active” alone does not prove that Docker ports are safe because Docker has its own port-forwarding rules. For every new service, check the Compose ports, Docker's actual bindings, and a connection from another machine. The service-addition guide has the commands, and the Build Log has the deployment timeline.

Reading path​

  1. Architecture and safety
  2. SSH access and hardening
  3. Tailscale networking and OpenSSH
  4. UFW without lockout
  5. Docker Engine and Compose
  6. Operations runbook
  7. Practical Tmux
  8. Adding services beside an existing bot
  9. RSSHub production shadow deployment

Safety invariants​

  • Label every command as local WSL2 or remote VPS.
  • Never remove the last verified SSH path.
  • After changing SSH or the firewall, test with a fresh connection; an old session can survive a broken firewall rule.
  • Never put private keys, passwords, authentication tokens, or recovery codes in command output, notes, Git, or chat records. Never publish real addresses or fingerprints either.

Example variables​

This series uses placeholders instead of real machine details:

VPS_USER=ubuntu
VPS_PUBLIC_IPV4="<public-ip>"
TAILSCALE_IPV4="<100.x.x.x>"
LOCAL_SSH_PUBLIC_KEY="$HOME/.ssh/id_ed25519.pub"

Do not store passwords, private keys, or a Tailscale auth key in these variables.

What is complete so far​

After Phases 1–3, my VPS has:

  • An updated Ubuntu system with the correct hostname and time zone.
  • OpenSSH configured for public-key-only authentication, with direct root login disabled.
  • Working OpenSSH sessions over the Tailscale private network.
  • Public TCP 80/443 available for Web ingress, with port 22 no longer generally accessible.
  • Working Docker, Buildx, and Compose, with Caddy, Uptime Kuma, and RSSHub in independent Compose projects.
  • Caddy as the only public container ingress, while Kuma's management interface remains loopback-only.
  • RSSHub running an official digest-pinned image with memory cache, ACCESS_KEY, and no host port.
  • Verified public HTTPS health checks, unauthenticated denial, an authenticated real route, monitoring, and application restart.
  • Service configuration and persistent data under /srv; having a data directory does not mean having a backup.
  • Tmux available to keep operations sessions running, with additional services added gradually as independent Compose projects.

The feed consumer's production configuration, persistent data, scheduler, and local RSSHub remain unchanged. Consumer cutover, backup and restore proof, scheduler migration, and the decision to retire the local fallback belong to Phase 4.

Official resources​

Explore connectionsOpen network