# Deploy to VPS — full published content > 158 articles. Source: https://deploytovps.com --- ## AI-assisted beginner deployments need a source-of-truth checklist before hosting breaks Source: https://deploytovps.com/blog/guide/ai-assisted-deployment-source-of-truth-checklist Published: 2026-06-25 Tags: hostinger, github, domain, beginner-deployment, ai-coding, guide, servercompass Several fresh beginner threads point at the same gap between AI-generated instructions, website builders, GitHub, and real hosting. One user moved from Hostinger Horizon to Cursor/GitHub/Supabase to avoid higher builder pricing, but cannot tell why changes… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Several fresh beginner threads point at the same gap between AI-generated instructions, website builders, GitHub, and real hosting. One user moved from Hostinger Horizon to Cursor/GitHub/Supabase to avoid higher builder pricing, but cannot tell why changes are not syncing to Hostinger. A first-time freelancer is building a client coaching site with only HTML/CSS/JS plus AI, while needing lead capture, fees, notes, hosting, domain choices, and backend boundaries. A WordPress beginner bought a domain but learned the plan still blocks using it as the primary domain, exposing confusion between domain registration, hosted WordPress, and publishing. This supports a ServerCompass guide for first deployments: choose one source of truth, confirm what the host actually deploys, separate domain purchase from app hosting, and verify the live URL after every change That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure EnthusiasmSerious439 has Cursor, GitHub, Supabase, and Hostinger connected but cannot get live Hostinger state to reflect local/GitHub changes. Illustrious_Film_713 is taking a first freelance website project with AI help and unclear backend/hosting/domain pitfalls. AccurateWall9863 bought a WordPress domain and then discovered publishing/primary-domain plan constraints. The recurring signal is that AI-assisted beginners need a deployment source-of-truth and hosting/domain checklist before they can reliably ship. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/webdev/comments/1uejq03/github_supabase_cursor_and_hostinger_not_syncing/), [thread 2](https://www.reddit.com/r/webdev/comments/1uekc8r/my_first_freelance_gig_2ndyear_btech_building_a/), [thread 3](https://www.reddit.com/r/Wordpress/comments/1udrlfe/total_noob_question_about_wordpress_domain_and/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `hostinger`, `github`, `domain`, `beginner-deployment`, `ai-coding`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## AI-assisted self-hosting breaks when logs, installers, and runtime state cross layers Source: https://deploytovps.com/blog/guide/ai-assisted-self-hosting-debug-layer-isolation Published: 2026-06-25 Tags: proxmox, debugging, self-hosting, logs, troubleshooting, guide, servercompass Fresh support threads show a useful content angle: AI and docs can get users most of the way through self-hosting, but the hard failures happen across boundaries. One user spent two days trying to install uCore/CoreOS in a Proxmox VM with qcow2 and ignition… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh support threads show a useful content angle: AI and docs can get users most of the way through self-hosting, but the hard failures happen across boundaries. One user spent two days trying to install uCore/CoreOS in a Proxmox VM with qcow2 and ignition files after AI-guided ISO/cidata steps failed. Another new Proxmox build freezes during install on one hardware set while the same USB works elsewhere. A Synology user running Grimmory plus an Audiobookshelf sync bridge keeps hitting API read timeouts after rebuilding the library and removing the bridge. This supports a ServerCompass article on debugging by layer: hardware/installer, VM image/cloud-init or ignition, reverse proxy, app API, storage, and logs That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Zer0CoolXI followed AI-generated Proxmox/CoreOS/uCore steps and hit ignition/cidata boot errors. Junior-Library-787 has Proxmox installer kernel panic/freezing on new Z790/i9 hardware while another machine installs fine from the same USB. CrrackTheSkye has a self-hosted app integration timing out against a Synology-hosted library even after deleting and rebuilding components. The recurring signal is that self-hosting debug help should force layer isolation instead of jumping between commands. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1uegq9v/help_installing_ucore_vm_on_proxmox/), [thread 2](https://www.reddit.com/r/Proxmox/comments/1ueengn/proxmox_kenel_panic/), [thread 3](https://www.reddit.com/r/selfhosted/comments/1uegfqx/going_crazy_trying_to_diagnose_an_issue_with/). ![Grafana monitoring template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/grafana/04-select-grafana-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `proxmox`, `debugging`, `self-hosting`, `logs`, `troubleshooting`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Boot path:** Know whether the server can boot without the data pool, without the USB stick, and after a failed upgrade. - **Storage semantics:** Record which numbers are quota, reservation, snapshot, parity, used blocks, and logical file size; they do not mean the same thing. - **Configuration backup:** Export the hypervisor, NAS, container, and app configs separately from bulk data so recovery is not all-or-nothing. - **Restore sample:** Restore one VM, one container volume, and one user-facing file before changing disks or filesystems. - **Rollback stop point:** Define the exact symptom that means stop, roll back, and preserve evidence instead of trying one more fix. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Boot path | Know whether the server can boot without the data pool, without the USB stick, and after a failed upgrade. | | Storage semantics | Record which numbers are quota, reservation, snapshot, parity, used blocks, and logical file size; they do not mean the same thing. | | Configuration backup | Export the hypervisor, NAS, container, and app configs separately from bulk data so recovery is not all-or-nothing. | | Restore sample | Restore one VM, one container volume, and one user-facing file before changing disks or filesystems. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/grafana/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Beginner homelabs need an app-first checklist before buying storage, servers, or noisy rack gear Source: https://deploytovps.com/blog/guide/beginner-homelab-app-first-checklist-before-hardware Published: 2026-06-25 Tags: beginner-homelab, storage-planning, immich, jellyfin, security-audit, guide, servercompass Very fresh homelab and self-hosted threads keep showing beginners choosing hardware before they have a stable application plan. One user already runs Immich and Jellyfin on an old laptop but is unsure what storage size, drive class, and server path fit a… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Very fresh homelab and self-hosted threads keep showing beginners choosing hardware before they have a stable application plan. One user already runs Immich and Jellyfin on an old laptop but is unsure what storage size, drive class, and server path fit a media plus office-style stack. Another asks what self-hosters forget before starting at all. A third bought a server that is too loud for a bedroom and is considering unsafe cooling modifications just to make it livable. A security writeup shows the next-stage failure mode: once services work, SSH, exposed Nextcloud/Pi-hole, root containers, and forgotten ports can remain unaudited. This supports a practical ServerCompass article that starts with the apps and operating model, then picks hardware, storage, remote access, security, backups, and monitoring in that order That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure KeyEnthusiasm6697 is using an old laptop for Immich/Jellyfin and wants to build a media and office-like homelab but is stuck on storage/server choices. GurDesperate1242 asks what cannot be forgotten when starting self-hosting. eski132 bought a loud room server and is considering cheap physical cooling hacks. frisk2007 later found SSH on port 22, exposed Nextcloud/Pi-hole, root containers, and forgotten open ports after months of adding services. The recurring need is a beginner runbook that orders decisions by app requirements, environment, storage, exposure, backup, and monitoring instead of buying hardware first. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1uewy2l/how_do_i_get_into_homelabbing/), [thread 2](https://www.reddit.com/r/selfhosted/comments/1uenv3n/what_is_the_most_important_thing_that_cant_be/), [thread 3](https://www.reddit.com/r/homelab/comments/1uevcqr/this_thing_is_so_loud_what_should_i_do/), [thread 4](https://www.reddit.com/r/homelab/comments/1uej95e/my_homelab_security_was_embarrassing_until_i/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `beginner-homelab`, `storage-planning`, `immich`, `jellyfin`, `security-audit`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Beginners need one exposure model before mixing tunnels, reverse proxies, VLANs, and Tailscale Source: https://deploytovps.com/blog/guide/beginner-homelab-exposure-model-tunnels-proxies-vlans Published: 2026-06-25 Tags: beginner-homelab, reverse-proxy, tailscale, cloudflare-tunnel, security, guide, servercompass Fresh self-hosting threads show beginners combining the right words without a safe operating model. One new self-hoster wants Minecraft, Jellyfin, Tailscale, maybe NAS/cloud storage, Nginx, Cloudflare tunnels or DDNS, Docker or VLANs, fail2ban, firewall… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh self-hosting threads show beginners combining the right words without a safe operating model. One new self-hoster wants Minecraft, Jellyfin, Tailscale, maybe NAS/cloud storage, Nginx, Cloudflare tunnels or DDNS, Docker or VLANs, fail2ban, firewall rules, and Proxmox, but cannot yet tell which parts protect what. Another new homelabber has mini PCs and wants Plex, Pi-hole, reverse proxy, remote access, self-hosted portfolios, and local LLM workflows, but needs an OS and container/VM path. A homelab security audit post found exposed Nextcloud, public Pi-hole admin, SSH password auth, and root containers months after everything seemed to work. This supports a ServerCompass guide about choosing one exposure model first: tailnet-only, public reverse proxy, or tunnel, then mapping ports, auth, backups, and service ownership before adding more tools That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Day_Old_Gatorade lists Cloudflare tunnels, DDNS, reverse proxy, Docker, VLANs, firewall, fail2ban, Jellyfin, Minecraft, and Tailscale while asking what is actually safe. el_Krango wants VMs/containers, Plex, Pi-hole, reverse proxy, remote access, self-hosted portfolios, and local LLM workflows on mini PCs but needs a setup path. frisk2007 only found exposed Nextcloud, public Pi-hole admin, SSH password auth, and root containers after auditing a lab that had already been running for months. The recurring signal is that beginners need a single exposure model and verification checklist before mixing every self-hosting pattern at once. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/selfhosted/comments/1ueq1oh/general_guidance_on_hardware_and_security_for_a/), [thread 2](https://www.reddit.com/r/homelab/comments/1uesh0p/new_to_homelabbing_windows_or_linux_on_these_mini/), [thread 3](https://www.reddit.com/r/homelab/comments/1uej95e/my_homelab_security_was_embarrassing_until_i/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `beginner-homelab`, `reverse-proxy`, `tailscale`, `cloudflare-tunnel`, `security`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Container monitoring is moving from uptime dashboards to egress policy, backup-state widgets, and service-level proof Source: https://deploytovps.com/blog/guide/container-monitoring-egress-backup-service-proof Published: 2026-06-25 Tags: monitoring, podman, docker, backups, homelab, guide, servercompass Fresh self-hosting posts point to a more specific monitoring need than simple green/red uptime. One homelab user running rootless Podman quadlet services wants per-container outbound blocking, domain allowlists, and traffic visibility but cannot see how to… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh self-hosting posts point to a more specific monitoring need than simple green/red uptime. One homelab user running rootless Podman quadlet services wants per-container outbound blocking, domain allowlists, and traffic visibility but cannot see how to attach ntopng to rootless networking. Another dashboard showcase combines Beszel, Dockhand, and Borgmatic status so the operator can see server health, container management, and backup timing in one place. Earlier same-day n8n and monitoring threads reinforce the same pattern: small operators want service-level proof that containers are healthy, constrained, backed up, and not silently drifting. ServerCompass can turn this into a practical guide on container observability without building a full platform team That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure pok1yoyo asks how to monitor rootless quadlet traffic and enforce per-container outbound allowlists when rootless Podman networking hides the normal interfaces. xhaythemx shows a Glance dashboard with OpenCloud, Immich, Beszel, Dockhand, and Borgmatic backup-state widgets. West-Inspection7096 from the same scan asks for a monitoring project that spans server, network, logs, security, and local AI analysis. The repeated signal is that self-hosters want container-level evidence and policy, not just an uptime page. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1uebidp/rootless_quadlet_network_traffic_monitoring_ntopng/), [thread 2](https://www.reddit.com/r/selfhosted/comments/1ue9pc0/my_glance_dashboard/), [thread 3](https://www.reddit.com/r/n8n/comments/1ue4ffl/need_help_with_a_monitoring_project/). ![Grafana monitoring template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/grafana/04-select-grafana-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `monitoring`, `podman`, `docker`, `backups`, `homelab`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Boot path:** Know whether the server can boot without the data pool, without the USB stick, and after a failed upgrade. - **Storage semantics:** Record which numbers are quota, reservation, snapshot, parity, used blocks, and logical file size; they do not mean the same thing. - **Configuration backup:** Export the hypervisor, NAS, container, and app configs separately from bulk data so recovery is not all-or-nothing. - **Restore sample:** Restore one VM, one container volume, and one user-facing file before changing disks or filesystems. - **Rollback stop point:** Define the exact symptom that means stop, roll back, and preserve evidence instead of trying one more fix. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Boot path | Know whether the server can boot without the data pool, without the USB stick, and after a failed upgrade. | | Storage semantics | Record which numbers are quota, reservation, snapshot, parity, used blocks, and logical file size; they do not mean the same thing. | | Configuration backup | Export the hypervisor, NAS, container, and app configs separately from bulk data so recovery is not all-or-nothing. | | Restore sample | Restore one VM, one container volume, and one user-facing file before changing disks or filesystems. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/grafana/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Small web app owners need deployment edge checks for domains, CORS, certificates, and managed-hosting handoffs Source: https://deploytovps.com/blog/guide/deployment-edge-checks-domains-cors-certificates Published: 2026-06-25 Tags: deployment, domains, cors, ssl, wordpress, guide, servercompass Fresh webdev and hosting threads show solo builders struggling at the boundary between app code and the platform edge. A MERN app with a Docker backend on Hugging Face Spaces and a Vercel frontend broke because an edge/proxy layer stopped returning the… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh webdev and hosting threads show solo builders struggling at the boundary between app code and the platform edge. A MERN app with a Docker backend on Hugging Face Spaces and a Vercel frontend broke because an edge/proxy layer stopped returning the expected CORS credentials header. A WordPress beginner bought a domain but still could not use it without understanding the hosting-plan boundary. Another managed WordPress customer reports a faulty install and opaque escalation. A HAProxy operator learned that ACME success is not the same as proving the edge is serving the renewed certificate. This supports a ServerCompass guide on post-deploy edge verification: DNS, domains, TLS, CORS, provider proxies, and rollback exits That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Prestigious_Run4913 has a Dockerized MERN backend on Hugging Face Spaces and a Vercel frontend where OPTIONS preflight responses lost Access-Control-Allow-Credentials despite Express config. AccurateWall9863 bought a WordPress domain but got stuck at the domain/hosting plan boundary. Effective_Energy4238 describes a managed WordPress install that stayed broken for months with poor escalation. SuccessFearless2102 explains the operational lesson that HAProxy ACME success must be verified at the served edge. The repeated issue is deployment handoff risk, not framework code alone. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/webdev/comments/1ucl5b7/hugging_face_spaces_proxy_suddenly_stripping/), [thread 2](https://www.reddit.com/r/Wordpress/comments/1udrlfe/total_noob_question_about_wordpress_domain_and/), [thread 3](https://www.reddit.com/r/Wordpress/comments/1ue9293/rwebhosting/), [thread 4](https://www.reddit.com/r/devops/comments/1udsq7m/haproxy_native_acme_worked_proving_it_served_the/). ![WordPress deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/wordpress/04-select-wordpress-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `deployment`, `domains`, `cors`, `ssl`, `wordpress`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/wordpress/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Small self-hosted sites fail at domain, media, Cloudflare, and app-runtime edges before the app idea fails Source: https://deploytovps.com/blog/guide/domain-cloudflare-media-runtime-preflight-small-sites Published: 2026-06-25 Tags: wordpress, ghost, domains, cloudflare, self-hosted-apps, guide, servercompass Fresh WordPress, Ghost, and self-hosted app threads show non-enterprise operators getting stuck around the deployment edges: domains that do not attach cleanly, media management after a migration, Cloudflare/page-load behavior, and self-hosted search tools… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh WordPress, Ghost, and self-hosted app threads show non-enterprise operators getting stuck around the deployment edges: domains that do not attach cleanly, media management after a migration, Cloudflare/page-load behavior, and self-hosted search tools that hit rate limits on small hardware. This is a good ServerCompass content angle about proving a small site or app is actually deployable and maintainable before blaming the software choice That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure AccurateWall9863 bought a domain for a simple WordPress audio site and is confused by the hosted domain/primary-domain flow. Witty-Surprise9176 migrated from WordPress to Ghost and found media management surprisingly fiddly. SelfGullible2092 sees a first-page white-screen flash on an Elementor/Cloudflare WordPress site. p4STAH tried lightweight self-hosted search aggregators on a Raspberry Pi and ran into poor results, rate limits, and blocking. The recurring signal is small operators needing a deployability checklist across DNS/domain attachment, edge/cache behavior, media/app data, hardware limits, and rollback. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/Wordpress/comments/1udrlfe/total_noob_question_about_wordpress_domain_and/), [thread 2](https://www.reddit.com/r/Ghost/comments/1ucdr0a/mediamanagement_migrated_from_wordpress_to_ghost/), [thread 3](https://www.reddit.com/r/Wordpress/comments/1udc4lr/white_screen_flash_on_1st_page_visit_only_then/), [thread 4](https://www.reddit.com/r/selfhosted/comments/1ued2xb/search_engine_aggregators/). ![WordPress deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/wordpress/04-select-wordpress-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `wordpress`, `ghost`, `domains`, `cloudflare`, `self-hosted-apps`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/wordpress/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## First-time self-hosted app launches need preflight maps before Docker, hosting plans, and per-client stacks Source: https://deploytovps.com/blog/guide/first-self-hosted-app-launch-preflight-map Published: 2026-06-25 Tags: docker, immich, nextjs, wordpress, deployment, guide, servercompass Fresh Reddit threads show beginners and small freelancers getting blocked before the application itself is the hard part. One Immich beginner wants to run it on Windows but gets stuck at Docker prerequisites. A Next.js freelancer is planning a separate app… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh Reddit threads show beginners and small freelancers getting blocked before the application itself is the hard part. One Immich beginner wants to run it on Windows but gets stuck at Docker prerequisites. A Next.js freelancer is planning a separate app, PostgreSQL database, and n8n instance for every salon client and wants to know where the backend boundary should be. A WordPress beginner bought a domain but did not understand the hosting-plan boundary needed to actually publish files. This supports a ServerCompass guide on app-first deployment preflights: where the app runs, what Docker requires, what owns the database, what backs up state, and what domain/hosting step proves the app is live That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Ok-Recover7069 wants to start Immich on Windows but is blocked by Docker setup language. digitalatanu123 is designing repeated Next.js, PostgreSQL, and n8n deployments for local-business clients and wants gotchas before choosing API routes versus a separate backend. AccurateWall9863 bought a WordPress domain but discovered the domain/hosting boundary was still unclear. The common signal is that new deployers need a concrete deployment map before they can judge framework or app choices. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/immich/comments/1ue9zjc/need_help_starting/), [thread 2](https://www.reddit.com/r/nextjs/comments/1ucdfj0/building_a_salon_website_admin_panel_whatsapp/), [thread 3](https://www.reddit.com/r/Wordpress/comments/1udrlfe/total_noob_question_about_wordpress_domain_and/). ![Immich deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `docker`, `immich`, `nextjs`, `wordpress`, `deployment`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Home Assistant reliability needs event evidence before adding Proxmox, Frigate, or auto-remediation Source: https://deploytovps.com/blog/guide/home-assistant-reliability-evidence-before-complexity Published: 2026-06-25 Tags: home-assistant, proxmox, frigate, monitoring, mdns, guide, servercompass Recent Home Assistant threads show operators trying to decide whether more infrastructure will help when the missing piece is often evidence. One user with a stable bare-metal Home Assistant mini PC is adding cameras and wonders whether Proxmox or Frigate is… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Recent Home Assistant threads show operators trying to decide whether more infrastructure will help when the missing piece is often evidence. One user with a stable bare-metal Home Assistant mini PC is adding cameras and wonders whether Proxmox or Frigate is worth the migration and reformat risk. Another has an Android TV turning itself on every 7-9 days with no obvious HA events or server logs, making even an auto-shutdown workaround risky. A Matter troubleshooting post only found relief after mDNS changes in UniFi, but the author still cannot explain why pairing finally worked. This supports a ServerCompass guide about reliability changes: capture event history, logs, network state, and recovery criteria before virtualizing, adding Frigate, or automating around unknown failures That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Scotching123 has a stable Home Assistant mini PC and wants to know whether Proxmox or Frigate is worth adding for Reolink cameras, but worries about complexity and reconfiguration pain. Ericbaudur has a Hisense Android TV turning itself on randomly every 7-9 days with no clear HA log trail and wants a safe way to remediate without disrupting normal use. Monsaki got IKEA Matter devices working after mDNS changes in UniFi, but still lacks a causal explanation. The recurring signal is that Home Assistant reliability work should start with evidence capture and rollback criteria before adding virtualization, camera stacks, or automated fixes. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homeassistant/comments/1uehfw5/help_me_decide_if_i_need_proxmox_and_or_frigate/), [thread 2](https://www.reddit.com/r/homeassistant/comments/1uefjai/android_tv_keeps_turning_itself_on/), [thread 3](https://www.reddit.com/r/homeassistant/comments/1uep7yb/finally_got_ikea_matter_devices_connected_after/). ![Home Assistant deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/home-assistant/04-select-home-assistant-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `home-assistant`, `proxmox`, `frigate`, `monitoring`, `mdns`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/home-assistant/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Power, solar, UPS, and DNS roles are becoming first-class homelab monitoring targets Source: https://deploytovps.com/blog/guide/home-server-monitoring-beyond-uptime-power-dns-certificates Published: 2026-06-25 Tags: monitoring, ups, homeassistant, pihole, dns, guide, servercompass Recent home-ops threads show monitoring expanding beyond whether a web app responds. Operators want to catch solar-panel variance, UPS and power-failure risk, fixtures killing devices after flashes, and whether a backup Pi-hole still boots into the right… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Recent home-ops threads show monitoring expanding beyond whether a web app responds. Operators want to catch solar-panel variance, UPS and power-failure risk, fixtures killing devices after flashes, and whether a backup Pi-hole still boots into the right role. This is a ServerCompass content angle about monitoring the physical and network dependencies that keep self-hosted apps reachable That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure ArchmichaelBishop tried to ask about UPS monitoring for self-hosted gear. geekdaddy01 wants Home Assistant visibility into each solar panel to catch reliability and efficiency problems. llIIllIllIIlIllIIIlI has repeated smart-bulb failures after power flashes or outages and wants to isolate the fixture issue. ServeMaster101 wants a Raspberry Pi to be both kiosk display and backup Pi-hole without losing DNS duties. The repeated signal is that home-server monitoring now needs power, energy, DNS role, and boot-state checks, not only HTTP uptime. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/selfhosted/comments/1uev0ty/is_your_discord_server_effing_up/), [thread 2](https://www.reddit.com/r/homeassistant/comments/1uejnzk/how_to_monitor_each_solar_panel_to_watch_for/), [thread 3](https://www.reddit.com/r/homeassistant/comments/1uevq0f/has_anyone_encountered_a_bulb_killing_fixture/), [thread 4](https://www.reddit.com/r/pihole/comments/1ue83ek/running_pihole_simultaneously/). ![Pi-hole deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/pihole/04-select-pihole-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `monitoring`, `ups`, `homeassistant`, `pihole`, `dns`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/pihole/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Home-server reliability starts with UPS shutdowns, tested boot media, and rollback paths before the next reboot Source: https://deploytovps.com/blog/guide/home-server-ups-boot-media-rollback-before-reboot Published: 2026-06-25 Tags: ups, unraid, backup, recovery, truenas, guide, servercompass Fresh homelab and Unraid posts show reliability pain clustering around the parts operators only test during failures: UPS/NUT compatibility and safe shutdown timing, Unraid 7.3.1 boot-device hangs, ZFS-to-Btrfs recovery after corruption and kernel panics… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh homelab and Unraid posts show reliability pain clustering around the parts operators only test during failures: UPS/NUT compatibility and safe shutdown timing, Unraid 7.3.1 boot-device hangs, ZFS-to-Btrfs recovery after corruption and kernel panics, and TrueNAS CPU pegging that only shows up through monitoring. A ServerCompass article can turn this into a pre-reboot reliability checklist for small servers: choose UPS hardware that exposes usable NUT data, test shutdown automation, clone and boot-test USB media, document storage rollback steps, watch hardware/OS resource anomalies, and keep app recovery notes close to the deploy workflow That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Both-Activity6432 is buying a UPS specifically to run long enough for clean shutdown and wants NUT compatibility at low cost. Ceaserxl solved an Unraid 7.3.1 boot-device hang only after stepping through 7.3.0 and preserving config, showing boot media and rollback fragility. MundanePercentage674 describes ZFS data corruption, kernel panic, USB rescue, daily backup config restore, and a migration to Btrfs. Master_Scythe found a TrueNAS CLI process pegging CPU and memory unexpectedly. These are all small-server reliability failures that benefit from preflight checks before the next reboot, upgrade, or power event. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1ue32so/ups_buying_advice/), [thread 2](https://www.reddit.com/r/unRAID/comments/1ue2fsm/731_boot_issue_stuck_at_waiting_for_boot_devices/), [thread 3](https://www.reddit.com/r/unRAID/comments/1udm44g/update_on_the_zfs_chaos_switched_to_btrfs_and_its/), [thread 4](https://www.reddit.com/r/truenas/comments/1ue00w4/you_can_peg_your_cpu_thread_at_100_with_a_keyboard/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `ups`, `unraid`, `backup`, `recovery`, `truenas`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Restarting a Jellyfin homelab on TrueNAS, ZimaOS, or Ubuntu is an app-data and rollback decision first Source: https://deploytovps.com/blog/guide/jellyfin-homelab-os-rebuild-app-data-rollback Published: 2026-06-25 Tags: homelab-os, jellyfin, truenas, unraid, rollback, guide, servercompass Fresh homelab and NAS threads show operators treating OS choice as a clean install question when the real risk is app-data ownership, boot-device recovery, and rollback after the first reboot. A ServerCompass article can frame OS selection around where… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh homelab and NAS threads show operators treating OS choice as a clean install question when the real risk is app-data ownership, boot-device recovery, and rollback after the first reboot. A ServerCompass article can frame OS selection around where Jellyfin, Docker volumes, DNS roles, and backups live before users wipe Ubuntu for TrueNAS, ZimaOS, or Unraid That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Radiant-Equipment-80 has Ubuntu Server with Docker and Jellyfin, has not attached storage yet, and is deciding whether to restart on ZimaOS or TrueNAS. ceaserxl lost time on an Unraid 7.3.1 boot-device update path and only recovered by stepping through 7.3.0 first. MundanePercentage674 described ZFS chaos, data corruption after an upgrade, kernel panic, USB rescue, and a Btrfs switch. ServeMaster101 wants one Pi 5 to be both backup Pi-hole and kiosk display without breaking DNS behavior at boot. The common signal is that homelab OS choices need app-data, boot, backup, and rollback proofs before reinstalling. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1uf19ls/looking_for_a_os/), [thread 2](https://www.reddit.com/r/unRAID/comments/1ue2fsm/731_boot_issue_stuck_at_waiting_for_boot_devices/), [thread 3](https://www.reddit.com/r/unRAID/comments/1udm44g/update_on_the_zfs_chaos_switched_to_btrfs_and_its/), [thread 4](https://www.reddit.com/r/pihole/comments/1ue83ek/running_pihole_simultaneously/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `homelab-os`, `jellyfin`, `truenas`, `unraid`, `rollback`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Small teams need a safe sequence for turning manual server edits into deployable operations Source: https://deploytovps.com/blog/guide/manual-server-edits-to-repeatable-deploy-operations Published: 2026-06-25 Tags: deployment, devops, vps, nginx, ssl, guide, servercompass Fresh webdev and DevOps threads show small teams outgrowing hand-maintained servers. One new DevOps hire inherited Laravel and Node apps where .env files, Nginx configs, dependencies, background jobs, secrets, database changes, and logs all live partly… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh webdev and DevOps threads show small teams outgrowing hand-maintained servers. One new DevOps hire inherited Laravel and Node apps where .env files, Nginx configs, dependencies, background jobs, secrets, database changes, and logs all live partly outside a repeatable process. Another web developer runs eight mixed PHP, Node, and Ruby projects on one Debian VPS and is worried about an OS upgrade/migration. A HAProxy operator highlighted that even successful ACME automation still needs proof that the edge is serving the right certificate. This supports a ServerCompass guide for migrating from artisanal servers to safe deploys in sequence: inventory, backups, config capture, deploy path, edge proof, and logs That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure InternationalToe4189 wants to move a revenue-generating Laravel/Node environment away from manual server edits, hardcoded secrets, server-local logs, ad hoc jobs, and DBA-email migrations without breaking trust. 1Luc1 hosts eight projects on one Debian VPS and is concerned about OS migration risk as Debian 11 reaches EOL. SuccessFearless2102 learned that ACME success is not enough unless HAProxy is proven to serve the expected cert at the public edge. The recurring signal is that small teams need a low-risk migration path from hand-edited servers to repeatable deploy operations. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/devops/comments/1ueh05l/how_to_start_the_whole_team_transition_to/), [thread 2](https://www.reddit.com/r/webdev/comments/1ucb08m/hosting_multiple_web_projects_on_one_server/), [thread 3](https://www.reddit.com/r/devops/comments/1udsq7m/haproxy_native_acme_worked_proving_it_served_the/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `deployment`, `devops`, `vps`, `nginx`, `ssl`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Media servers moved into VMs and containers need mount and client-resume proof before family users rely on them Source: https://deploytovps.com/blog/guide/media-server-vm-container-mount-client-proof Published: 2026-06-25 Tags: jellyfin, plex, proxmox, nas, media-server, guide, servercompass Fresh Jellyfin, Plex, and media-server threads show that a media stack can look installed while still failing at the boundary users actually hit. One Jellyfin user runs the server in a NAS container and can start playback, but the desktop app on Bazzite… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh Jellyfin, Plex, and media-server threads show that a media stack can look installed while still failing at the boundary users actually hit. One Jellyfin user runs the server in a NAS container and can start playback, but the desktop app on Bazzite freezes or crashes after pausing certain files. Another moved Plex from a QNAP NAS into a Proxmox VM and can mount the NAS over NFS, but Plex cannot browse the media subfolders from the library picker. Recent Jellyfin migration posts also show client connectivity and library-state failures after updates or Plex cutovers. This supports a ServerCompass guide about proving media-server deploys end to end: host mounts, container/VM permissions, app-library scans, client resume behavior, and rollback checks before handing the stack to family users That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure TimDRX has Jellyfin running in a NAS container, but the desktop/Bazzite client freezes or crashes after pause/resume on specific files. nchh13 moved Plex into a Proxmox VM, mounted QNAP media over NFS with rw access, but Plex cannot see the expected subfolders in its library picker. JimothyBBB and jrmtz85 are recent supporting signals around Jellyfin client connectivity and update-state failures. The repeatable market need is media-server deployment proof across storage mounts, runtime permissions, client behavior, and rollback, not only successful installation. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/jellyfin/comments/1uexczb/freezecrash_after_pausing_certain_files/), [thread 2](https://www.reddit.com/r/Proxmox/comments/1uewx8i/plex_as_vm_in_proxmox_cant_read_media_from_nas/), [thread 3](https://www.reddit.com/r/jellyfin/comments/1ueupg1/roku_app_321_connectivity_issues/), [thread 4](https://www.reddit.com/r/jellyfin/comments/1uef186/update_gone_awry/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `jellyfin`, `plex`, `proxmox`, `nas`, `media-server`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Automation builders need VPS sizing checks before moving n8n and databases out of Docker Desktop Source: https://deploytovps.com/blog/guide/n8n-vps-sizing-persistence-preflight Published: 2026-06-25 Tags: n8n, vps, docker, postgres, automation, guide, servercompass Fresh n8n threads show small automation builders leaving local Docker Desktop and immediately hitting provider and resource-sizing uncertainty. One user wants to run n8n, Qdrant, Postgres, and Gotenberg on a production-like VPS without overpaying or… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh n8n threads show small automation builders leaving local Docker Desktop and immediately hitting provider and resource-sizing uncertainty. One user wants to run n8n, Qdrant, Postgres, and Gotenberg on a production-like VPS without overpaying or under-sizing. Other n8n posts ask about free VPS options or celebrate getting a first VPS deployment live. This supports a ServerCompass guide on the minimum viable VPS for automation stacks: memory budgets, Postgres persistence, Docker volume backups, health checks, logs, and when to resize instead of starting with an oversized server That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure SilasVarent is moving from local Docker Desktop to a VPS for n8n, Qdrant, Gotenberg, Postgres, and low-load workflows, and wants to choose between 1GB, 2GB, 4GB, and 8GB plans without wasting money. StayQuick5128 is asking about always-free VPS options for n8n. Outrageous_Storm7558 posted about a first n8n VPS deployment. The recurring market signal is not simply installation; it is choosing the right VPS, making persistence safe, and getting a production-like deployment path without platform complexity. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/n8n/comments/1udm079/what_vps_would_you_recommend_for_getting_started/), [thread 2](https://www.reddit.com/r/n8n/comments/1uapgfz/always_free_vps/), [thread 3](https://www.reddit.com/r/n8n/comments/1uc3o3o/i_deploy_my_first_n8n_in_a_vps/). ![n8n deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/n8n/04-select-n8n-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `n8n`, `vps`, `docker`, `postgres`, `automation`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Persistent services:** Separate n8n, Postgres, queues, file storage, and worker processes before sizing the VPS. - **Webhook address:** Test the public webhook URL through DNS, TLS, reverse proxy, and app routing before migrating credentials. - **Memory ceiling:** Run the heaviest normal workflow while watching process memory, swap, and database connections. - **Backup unit:** Back up the database, encryption key, workflow exports, and uploaded files together. - **Scale trigger:** Define the metric that means move from a small VPS to workers or a larger plan. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Persistent services | Separate n8n, Postgres, queues, file storage, and worker processes before sizing the VPS. | | Webhook address | Test the public webhook URL through DNS, TLS, reverse proxy, and app routing before migrating credentials. | | Memory ceiling | Run the heaviest normal workflow while watching process memory, swap, and database connections. | | Backup unit | Back up the database, encryption key, workflow exports, and uploaded files together. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/n8n/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## NAS-hosted Jellyfin failures cluster around bind mounts, library state, and update rollback Source: https://deploytovps.com/blog/guide/nas-jellyfin-bind-mount-library-rollback-checks Published: 2026-06-25 Tags: jellyfin, docker, portainer, nas, rollback, guide, servercompass Fresh Jellyfin and self-hosting posts show the same operator pain from multiple angles: the media server is running, but the boundary between Docker, NAS paths, and app state is fragile. One new self-hoster cannot deploy a Portainer/Jellyfin stack because… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh Jellyfin and self-hosting posts show the same operator pain from multiple angles: the media server is running, but the boundary between Docker, NAS paths, and app state is fragile. One new self-hoster cannot deploy a Portainer/Jellyfin stack because bind mounts with spaces and rw suffixes produce invalid Compose volume specs. Another Synology user updated Jellyfin in a container, deleted a config file suggested by AI, got the server running again, but lost the library and now needs to import backups. A Ugreen NAS user has Jellyfin reading one drive but not another after adding new media. This supports a practical ServerCompass checklist for NAS media apps: path quoting, read/write intent, config/database backups, and rollback tests before updates That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Drakeor is blocked deploying Jellyfin through Portainer because Docker bind-mount paths with spaces and repeated :rw suffixes become invalid volume specs. jrmtz85 had a Synology Jellyfin container update go sideways; deleting config let the service start but the library disappeared despite backups. genwawi runs Jellyfin on a Ugreen NAS and a second HDD stopped surfacing newly added media even though older media still works. The recurring signal is that NAS-hosted media apps fail at Docker path, app-state, and restore boundaries. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/selfhosted/comments/1uegqkn/portainer_issues/), [thread 2](https://www.reddit.com/r/jellyfin/comments/1uef186/update_gone_awry/), [thread 3](https://www.reddit.com/r/jellyfin/comments/1uda2y9/new_files_added_to_one_hard_drive_cannot_be_found/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `jellyfin`, `docker`, `portainer`, `nas`, `rollback`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Pi-to-NAS migrations need app-data and restore plans before RAID shopping Source: https://deploytovps.com/blog/guide/pi-to-nas-migration-app-data-restore-plan Published: 2026-06-25 Tags: nas, homelab, backups, jellyfin, immich, guide, servercompass Fresh homelab threads show users trying to move from small Raspberry Pi or improvised storage setups into dedicated NAS hardware while still deciding how app data, media, RAID, and backups should work. One operator wants to migrate a Pi NAS with Docker… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh homelab threads show users trying to move from small Raspberry Pi or improvised storage setups into dedicated NAS hardware while still deciding how app data, media, RAID, and backups should work. One operator wants to migrate a Pi NAS with Docker containers, three SSDs, and a backup HDD into a Ugreen NAS without losing data. Another is considering dropping RAID5 in favor of backup/failover-first storage because rebuild risk feels worse than restore risk. A third is planning a DIY JBOD for Jellyfin and Immich with mixed SAS/SATA parts. This supports a ServerCompass article that starts with app-data ownership and restore drills before hardware or RAID choices That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure purenimbus is moving a Raspberry Pi NAS plus Docker containers to dedicated NAS hardware and is unsure how to move data, choose RAID/storage pools, and automate backups. AppointmentNearby161 is rethinking RAID5 after URE/rebuild concerns and wants to lean on a local backup server/failover strategy instead. Mr_Viking442 is planning a compact DIY JBOD for Jellyfin and Immich and asks for failure points before buying parts. The recurring signal is that self-hosters need app-data, backup, and restore planning before storage topology decisions. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homelab/comments/1uegg0x/looking_to_switch_from_raspberry_pi_4_to/), [thread 2](https://www.reddit.com/r/homelab/comments/1uef2a4/picking_between_raid_0_lvm_stripe_lvm_type_raid0/), [thread 3](https://www.reddit.com/r/homelab/comments/1uefo1z/diy_jbod/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `nas`, `homelab`, `backups`, `jellyfin`, `immich`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Plex-to-Jellyfin migrations need library integrity and client-connection checks before the cutover Source: https://deploytovps.com/blog/guide/plex-to-jellyfin-migration-library-client-checks Published: 2026-06-25 Tags: jellyfin, plex-migration, immich, media-server, rollback, guide, servercompass Fresh Jellyfin and Immich threads show that media-server migration risk is not only about copying files. One user is moving from Plex to Jellyfin and sees music albums missing even though folders, permissions, and rescans look right. Another has Roku clients… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh Jellyfin and Immich threads show that media-server migration risk is not only about copying files. One user is moving from Plex to Jellyfin and sees music albums missing even though folders, permissions, and rescans look right. Another has Roku clients that connect then exit without activity logs, leaving them searching for the right diagnostics. A developer released a Plex-to-Jellyfin watch-status migration tool because existing options were unsatisfying. A high-engagement Immich migration post describes almost a year of testing and backup setup before deleting Google Photos. This supports a ServerCompass guide about proving media-server cutovers: library scans, metadata parity, playback/client checks, backup/restore proof, and rollback timing before family users lose access That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure grittybangbang has a mostly successful Plex-to-Jellyfin switch but music albums are missing despite folder access and rescans. JimothyBBB has Roku app 3.2.1 connection exits with no useful activity/device logs. dowitex built plex-to-jellyfin because watch-status and playback-position migration options were not satisfying. Chaosblast treated Immich as a year-long test with backup setup before deleting Google Photos. The recurring signal is that media-server migration advice needs integrity and client checks, not only install commands. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/jellyfin/comments/1ueuttp/not_all_music_is_showing_after_install/), [thread 2](https://www.reddit.com/r/jellyfin/comments/1ueupg1/roku_app_321_connectivity_issues/), [thread 3](https://www.reddit.com/r/jellyfin/comments/1uerj7s/migrate_watch_status_and_playback_positions_from/), [thread 4](https://www.reddit.com/r/immich/comments/1uddq1k/after_almost_a_year_of_setting_up_immich_today/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `jellyfin`, `plex-migration`, `immich`, `media-server`, `rollback`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Proxmox and NAS rebuilds need storage ownership decisions before the apps move Source: https://deploytovps.com/blog/guide/proxmox-nas-storage-ownership-before-apps-move Published: 2026-06-25 Tags: proxmox, truenas, nas, storage, jellyfin, guide, servercompass Several fresh homelab posts are really asking the same preflight question: who owns storage when the server is rebuilt? One Proxmox user is reinstalling across three SSDs before hosting OPNsense, Vaultwarden, Omada, Pi-hole, VPN, and other services. Another… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Several fresh homelab posts are really asking the same preflight question: who owns storage when the server is rebuilt? One Proxmox user is reinstalling across three SSDs before hosting OPNsense, Vaultwarden, Omada, Pi-hole, VPN, and other services. Another is moving a TrueNAS bare-metal NAS onto Proxmox and is unsure whether TrueNAS or Proxmox should own ZFS pools. A third is planning a new ECC NAS/Jellyfin build and is stuck on expansion choices for SAS, networking, or M.2. This can become a ServerCompass guide to storage ownership, backup boundaries, and app-data placement before a rebuild turns into a migration trap That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure yokie_dough is reinstalling Proxmox on an older Dell with three SATA SSDs and wants the right layout for routing plus small services. Kai-Soul is rebuilding two servers and moving TrueNAS bare metal into Proxmox, unsure whether TrueNAS or Proxmox should manage ZFS pools. benkay_86 is planning a W880 ECC NAS/Jellyfin build and needs expansion planning for HBA, NIC, or M.2. These are all storage-boundary decisions that should happen before app deployment, backups, and restore plans are locked in. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/Proxmox/comments/1ue567h/recommended_way_to_install_proxmox_across_3_sata/), [thread 2](https://www.reddit.com/r/Proxmox/comments/1ue6uog/media_server_config/), [thread 3](https://www.reddit.com/r/homelab/comments/1ue1ibt/advice_for_mcio_to_pcie_expansion_on_w880d4u/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `proxmox`, `truenas`, `nas`, `storage`, `jellyfin`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Reverse-proxy success needs application-level proof, not just a working domain Source: https://deploytovps.com/blog/guide/reverse-proxy-application-level-proof-after-domain-works Published: 2026-06-25 Tags: reverse-proxy, nextcloud, ssl, homelab-security, deployment-checks, guide, servercompass Fresh Reddit threads show self-hosters getting past the first domain or certificate milestone, then discovering that the application path is still broken. One Nextcloud snap user can reach the main app through a Synology reverse proxy, but Collabora… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh Reddit threads show self-hosters getting past the first domain or certificate milestone, then discovering that the application path is still broken. One Nextcloud snap user can reach the main app through a Synology reverse proxy, but Collabora redirects to a Synology error page when accessed from outside the LAN. A HAProxy operator got native ACME issuance working, then learned that the hard part was proving the edge actually served the new certificate and used the right key. A homelab security post found Nextcloud and Pi-hole exposed in ways the owner had forgotten. This supports a ServerCompass article about post-deploy proof: verify every app endpoint, document proxy exceptions, test TLS from the public edge, and audit what is actually open That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure ExpensiveSmell5495 has Nextcloud working on local IP and domain, but Collabora fails only from outside through Synology reverse proxy. SuccessFearless2102 reports that HAProxy ACME success was not enough without proving the public edge served the right cert/key. frisk2007 found old self-hosted services exposed publicly, including Nextcloud and Pi-hole, after months of focusing on adding services. The recurring signal is that self-hosters need endpoint-level verification after DNS, proxy, and certificate setup appears to work. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/NextCloud/comments/1ueixfb/ubuntu_nextcloud_snap_behind_synology_reverse/), [thread 2](https://www.reddit.com/r/devops/comments/1udsq7m/haproxy_native_acme_worked_proving_it_served_the/), [thread 3](https://www.reddit.com/r/homelab/comments/1uej95e/my_homelab_security_was_embarrassing_until_i/). ![Nextcloud deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/nextcloud/04-select-nextcloud-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `reverse-proxy`, `nextcloud`, `ssl`, `homelab-security`, `deployment-checks`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/nextcloud/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Self-hosted backup confidence breaks on quota, snapshot, and boot-device semantics rather than copy commands Source: https://deploytovps.com/blog/guide/self-hosted-backup-restore-proof-quota-snapshots-boot Published: 2026-06-25 Tags: backup, truenas, proxmox, unraid, restore-drill, guide, servercompass Recent TrueNAS, Proxmox, and Unraid posts show that backup anxiety is often caused by hidden platform semantics, not a missing rsync command. One TrueNAS user sees a Proxmox backup dataset reporting far more used space than its quota while daily snapshots… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Recent TrueNAS, Proxmox, and Unraid posts show that backup anxiety is often caused by hidden platform semantics, not a missing rsync command. One TrueNAS user sees a Proxmox backup dataset reporting far more used space than its quota while daily snapshots are retained. Unraid users are still dealing with boot-device update paths, ZFS corruption recovery, total-failure restore questions, and whether an external disk can stand in for parity. Older-looking rows remain useful because the same failure mode keeps appearing across platforms: people need to know what their backup numbers mean, when snapshots consume space, which boot media is recoverable, and whether a restore has actually been tested. This supports a ServerCompass guide about backup proof: source data, app data, snapshots, quotas, boot media, offsite copies, and a timed restore drill That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure fl4tdriven has a TrueNAS proxmox_backup dataset with quota/snapshot numbers that do not match intuitive stored size. ceaserxl reports an Unraid boot-device update path stuck at waiting for boot devices until stepping through an intermediate version. MundanePercentage674 describes ZFS chaos, backup restores, kernel panic, rescue USB, and a move to Btrfs. ajamils asks how to restore from total Unraid failure. equanimous11 asks whether a slow external drive can be a backup instead of parity. The recurring signal is that operators need backup and restore proof across platform semantics, not just a copy job. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/truenas/comments/1uew2z6/confused_on_dataset_allocation/), [thread 2](https://www.reddit.com/r/unRAID/comments/1ue2fsm/731_boot_issue_stuck_at_waiting_for_boot_devices/), [thread 3](https://www.reddit.com/r/unRAID/comments/1udm44g/update_on_the_zfs_chaos_switched_to_btrfs_and_its/), [thread 4](https://www.reddit.com/r/unRAID/comments/1ubys30/how_to_restore_from_total_failure/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `backup`, `truenas`, `proxmox`, `unraid`, `restore-drill`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Self-hosted document, photo, and media libraries need data-shape planning before the app choice Source: https://deploytovps.com/blog/guide/self-hosted-data-shape-planning-before-app-choice Published: 2026-06-25 Tags: paperless, immich, jellyfin, data-ownership, backup-planning, guide, servercompass A cluster of fresh self-hosted, Immich, and Jellyfin threads shows users stuck less on installation and more on how their real data should be organized: medical receipts, split photo archives, shared albums, kids libraries, and music loudness. This supports… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. A cluster of fresh self-hosted, Immich, and Jellyfin threads shows users stuck less on installation and more on how their real data should be organized: medical receipts, split photo archives, shared albums, kids libraries, and music loudness. This supports content around choosing templates and storage layouts by data shape, import path, and backup boundary before deploying the app That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure jmorx3 needs a self-hosted way to track IVF receipts, scans, HSA records, and insurance documents after starting with email forwards. sparkany argues that photo ownership spans phone photos, NAS/WebDAV archives, Immich or PhotoPrism views, old Google/iCloud exports, and backup drives. guardian1691 asks how to split kids and adult Jellyfin content cleanly without duplicating libraries. diraqan is new to self-hosting and wants a reliable music-library loudness normalization path because Jellyfin still has big swings. The durable insight is that the app template matters less than data shape, import rules, library boundaries, and recoverable backup ownership. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/selfhosted/comments/1uewmfu/receipt_tracking/), [thread 2](https://www.reddit.com/r/selfhosted/comments/1ue6ws1/photo_libraries_should_not_have_to_live_in_one/), [thread 3](https://www.reddit.com/r/jellyfin/comments/1uejucu/looking_for_recommendations_if_theres_a_better/), [thread 4](https://www.reddit.com/r/selfhosted/comments/1ue87cy/audio_volume_normalization_for_music_library_with/). ![Jellyfin deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `paperless`, `immich`, `jellyfin`, `data-ownership`, `backup-planning`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Self-hosted edge fixes need causal proof across mDNS, ACME, Caddy callbacks, and intermittent 404s Source: https://deploytovps.com/blog/guide/self-hosted-edge-fixes-causal-proof-mdns-acme-callbacks Published: 2026-06-25 Tags: reverse-proxy, mdns, acme, caddy, wordpress-404, guide, servercompass Fresh posts keep separating “the change worked once” from “the edge path is understood.” A Home Assistant user got IKEA Matter devices working after changing UniFi mDNS settings but still does not know why. A DevOps post says HAProxy ACME issuance worked… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh posts keep separating “the change worked once” from “the edge path is understood.” A Home Assistant user got IKEA Matter devices working after changing UniFi mDNS settings but still does not know why. A DevOps post says HAProxy ACME issuance worked, but proving the new certificate was actually served at the edge was the hard part. An n8n user on an Oracle VM with Dockerized n8n, Supabase, and Caddy is blocked by HubSpot app credential and callback assumptions. A WordPress user has Semplice project pages intermittently throwing 404s that clear after disabling Jetpack and resetting permalinks. This supports a ServerCompass guide about edge verification: DNS/mDNS, callback URLs, certificates, proxy routing, app logs, and repeatable proof after every fix That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure Monsaki got IKEA Matter devices connected after weeks and a UniFi mDNS change, but still lacks a causal explanation. SuccessFearless2102 describes HAProxy ACME succeeding while proving the served certificate at the edge was the real hard part. FriendlyAirline8881 runs n8n, Supabase, and Caddy on an Oracle VM while HubSpot app credentials and callback assumptions block the workflow. MichaelCoorlim has intermittent WordPress/Semplice 404s that temporarily clear after disabling Jetpack and resetting permalinks. The recurring signal is that edge fixes should end with repeatable proof, not just a green setup step. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/homeassistant/comments/1uep7yb/finally_got_ikea_matter_devices_connected_after/), [thread 2](https://www.reddit.com/r/devops/comments/1udsq7m/haproxy_native_acme_worked_proving_it_served_the/), [thread 3](https://www.reddit.com/r/n8n/comments/1uesygx/i_need_some_rigorous_guidance_for_my_multi/), [thread 4](https://www.reddit.com/r/Wordpress/comments/1uetgel/jetpack_semplice_issues/). ![Pi-hole deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/pihole/04-select-pihole-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `reverse-proxy`, `mdns`, `acme`, `caddy`, `wordpress-404`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/pihole/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Self-hosted monitoring buyers want logs, health, certificates, canaries, and local AI in one actionable workflow Source: https://deploytovps.com/blog/guide/self-hosted-monitoring-stack-logs-certs-canaries Published: 2026-06-25 Tags: monitoring, logs, grafana, certificates, n8n, guide, servercompass A fresh n8n thread asks how to build a self-hosted monitoring project that pulls server, app, network, security, and resource signals into Grafana-style views and uses local Ollama analytics. Nearby threads show adjacent operators solving narrower monitoring… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. A fresh n8n thread asks how to build a self-hosted monitoring project that pulls server, app, network, security, and resource signals into Grafana-style views and uses local Ollama analytics. Nearby threads show adjacent operators solving narrower monitoring failures: proving HAProxy actually loaded the renewed ACME cert, running zero-agent network canaries, and building Grafana dashboards that people actually look at. The ServerCompass angle is a practical monitoring architecture for small operators: collect the few signals that predict real outages, normalize logs and health checks, verify TLS at the edge, add canary-style checks, and trigger human-readable remediation workflows without requiring a heavy always-on control plane That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure West-Inspection7096 wants an open-source self-hosted monitoring stack using n8n, Grafana, logs, server/network health, security events, and local Ollama analytics. SuccessFearless2102 describes the operational trap where ACME succeeds but HAProxy may still serve the wrong certificate, making process-loaded cert verification more important than file-on-disk checks. AndReicscs is sharing zero-agent home-lab canaries, while Zydepo1nt discusses Grafana dashboard sprawl and making dashboards visible enough to use. Together these support a concrete guide to self-hosted monitoring that goes beyond uptime pages into logs, cert verification, canaries, health/resource signals, and workflow automation. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/n8n/comments/1ue4ffl/need_help_with_a_monitoring_project/), [thread 2](https://www.reddit.com/r/devops/comments/1udsq7m/haproxy_native_acme_worked_proving_it_served_the/), [thread 3](https://www.reddit.com/r/selfhosted/comments/1ue4mxq/honeywire_opensource_zeroagent_cyber_canaries_for/), [thread 4](https://www.reddit.com/r/selfhosted/comments/1udg050/recent_grafana_dashboards_interest_check/). ![n8n deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/n8n/04-select-n8n-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `monitoring`, `logs`, `grafana`, `certificates`, `n8n`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **External DNS answer:** `dig +short your-domain.example @1.1.1.1` should return the address the public internet will hit, not only the answer your LAN resolver gives you. - **TLS from outside the LAN:** `openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null` should show the certificate chain the browser will actually receive. - **HTTP behavior at the app route:** `curl -Iv https://your-domain.example/health` should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. - **Preflight and callback paths:** For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. - **Exposure inventory:** List every forwarded port, tunnel hostname, public admin page, and SSH path, then close anything that is not intentionally public. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | External DNS answer | dig +short your-domain.example @1.1.1.1 should return the address the public internet will hit, not only the answer your LAN resolver gives you. | | TLS from outside the LAN | openssl s_client -connect your-domain.example:443 -servername your-domain.example </dev/null should show the certificate chain the browser will actually receive. | | HTTP behavior at the app route | curl -Iv https://your-domain.example/health should exercise the reverse proxy, the upstream app, redirects, headers, and auth boundary together. | | Preflight and callback paths | For CORS, OAuth, webhooks, and Collabora-style integrations, test the exact public callback URL instead of assuming the base domain proves the app. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/n8n/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Self-hosted app upgrades need reversible database and boot-media plans before the next update Source: https://deploytovps.com/blog/guide/self-hosted-upgrade-preflight-database-boot-rollback Published: 2026-06-25 Tags: immich, unraid, rollback, backup-restore, upgrade-preflight, guide, servercompass Fresh threads keep showing upgrade risk at two layers: application data and the host boot path. An Immich user is staying on a custom Docker image because the database migration has no reverse path. An Unraid user describes data corruption, daily backup… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Fresh threads keep showing upgrade risk at two layers: application data and the host boot path. An Immich user is staying on a custom Docker image because the database migration has no reverse path. An Unraid user describes data corruption, daily backup restores, kernel panic, rescue USB boot, and a ZFS-to-Btrfs migration after an upgrade went bad. Another Unraid user solved a stuck boot-device issue only by stepping through an intermediate version while preserving the existing USB config. A high-engagement Immich migration post shows almost a year of testing and backup setup before deleting Google Photos. This supports a ServerCompass article about upgrade preflights: backup restore proof, database downgrade reality, config export, boot media replacement, and rollback windows for self-hosted apps That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure SoulInTransition is refusing an Immich Docker upgrade because there is no reverse database migration. MundanePercentage674 reports Unraid ZFS chaos after an upgrade, including corruption, backup restores, kernel panic, rescue USB boot, and a fast migration to Btrfs. ceaserxl spent days on an Unraid 7.3.1 stuck boot-device issue and cared most about preserving the existing config. Chaosblast treated Immich migration as a year-long testing and backup project before deleting Google Photos. The recurring signal is that update advice must include database reversibility, boot-media fallback, and restore proof, not just install commands. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/immich/comments/1ueqdlw/not_going_to_upgrade_until_fixed/), [thread 2](https://www.reddit.com/r/unRAID/comments/1udm44g/update_on_the_zfs_chaos_switched_to_btrfs_and_its/), [thread 3](https://www.reddit.com/r/unRAID/comments/1ue2fsm/731_boot_issue_stuck_at_waiting_for_boot_devices/), [thread 4](https://www.reddit.com/r/immich/comments/1uddq1k/after_almost_a_year_of_setting_up_immich_today/). ![Immich deployment template inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `immich`, `unraid`, `rollback`, `backup-restore`, `upgrade-preflight`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## Unraid operators need boot, network, and rollback proof before trusting a recovered server Source: https://deploytovps.com/blog/guide/unraid-recovery-boot-network-rollback-proof Published: 2026-06-25 Tags: unraid, homelab-recovery, network-monitoring, boot-media, rollback, guide, servercompass Several recent Unraid threads show home-server failures where the array or app stack may be fine but the operator lacks a verified recovery path. One server loses network every day even after a NIC and cable swap. Another becomes unresponsive through the web… A self-hosted service usually looks healthy right up until the moment a real person depends on it. The web UI loads once, the container says it is running, the dashboard has a green row, and everyone moves on. The problem is that most failures in small infrastructure do not start at the happy-path install command. They start at the boundary between data, network, power, storage, credentials, clients, and the next change you make under pressure. Several recent Unraid threads show home-server failures where the array or app stack may be fine but the operator lacks a verified recovery path. One server loses network every day even after a NIC and cable swap. Another becomes unresponsive through the web UI and then will not POST after reboot unless the USB boot device is removed. A third solved a boot-device issue only by stepping through an intermediate Unraid version. Another postmortem describes ZFS corruption, repeated backup restores, kernel panic, rescue USB boot, and a Btrfs switch after upgrade chaos. This supports a ServerCompass article about Unraid reliability checks: out-of-band console access, boot USB replacement, network path logging, rollback windows, and restore drills before the next reboot or upgrade That is the useful angle here: this is not another checklist for chasing a perfect homelab. It is a way to decide what must be true before you trust the service with real users, family data, client work, or a weekend migration. ## The pattern behind the failure spacedognj loses network to an Unraid server daily despite replacing the NIC and cable. invest0rz has an Unraid host that becomes unresponsive and will not POST after reboot unless the USB is removed. ceaserxl solved an Unraid 7.3.1 boot-device issue by stepping through 7.3.0 first while trying to preserve config. MundanePercentage674 describes upgrade-related ZFS chaos, backup restores, kernel panic, rescue USB, and a move to Btrfs. The recurring signal is that recovery planning must include boot media, network observability, console-side proof, and rollback criteria. Read that as a systems problem rather than a collection of unrelated tool complaints. One person may be looking at a NAS, another at a proxy, another at a media library, and another at a small VPS, but the shape is the same. A visible setup step succeeded while an invisible dependency stayed unproven. That invisible dependency is where most self-hosted work becomes expensive. If you discover it during planning, it is a note in a runbook. If you discover it after a power cut, upgrade, certificate reload, or family movie night, it becomes an outage with incomplete evidence. The source signal came from several current operator threads: [thread 1](https://www.reddit.com/r/unRAID/comments/1uesdwa/losing_network_daily/), [thread 2](https://www.reddit.com/r/unRAID/comments/1ueuyv2/computer_but_no_response_from_from_unraid/), [thread 3](https://www.reddit.com/r/unRAID/comments/1ue2fsm/731_boot_issue_stuck_at_waiting_for_boot_devices/), [thread 4](https://www.reddit.com/r/unRAID/comments/1udm44g/update_on_the_zfs_chaos_switched_to_btrfs_and_its/). ![ServerCompass app dashboard inside ServerCompass while choosing an app deployment path](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Use screenshots like this as a reminder to plan the deployment path, not only the app name.* ## Start with the promise the service is making Before choosing the next app, OS, dashboard, tunnel, or VPS size, write one plain sentence: what does this service promise to keep working? A media server promises that people can find and play the library from the devices they actually use. A monitoring stack promises that an alert explains what changed, not just that a URL stopped answering. A migration promises that old data can be restored and the cutover can be reversed. A public web app promises that DNS, TLS, CORS, uploads, and background jobs all agree about the same production address. That sentence gives you the operating boundary. It tells you which checks matter and which impressive-looking tooling can wait. It also keeps the plan grounded when a thread, tutorial, or AI assistant starts suggesting a pile of extra components. For this topic, the relevant signals are `unraid`, `homelab-recovery`, `network-monitoring`, `boot-media`, `rollback`. Treat those tags as dependencies to prove. They are not just SEO labels; they are the parts of the system most likely to make the difference between a service that starts and a service that can be trusted. ## The preflight map Use this sequence before the install, migration, upgrade, or hardware purchase becomes irreversible: - **Data owner:** Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. - **Mount proof:** `docker compose exec app ls -la /media` or the equivalent VM check should show the same folders the app picker is expected to scan. - **Client proof:** Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. - **Rollback boundary:** Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. - **Restore drill:** Restore a small folder, a database dump, and one app config file into a temporary path before trusting the backup policy. The point is not to turn every home server into enterprise process. The point is to make the next hour of work reversible. A short written preflight catches the assumption that would otherwise stay hidden until the service is live. ## What this looks like in practice | Area | Proof you want before trusting it | |---|---| | Data owner | Write down which system owns the original files, which app owns metadata, and where exports or sidecars will live. | | Mount proof | docker compose exec app ls -la /media or the equivalent VM check should show the same folders the app picker is expected to scan. | | Client proof | Test the weakest real client, such as a Roku, mobile device, web player, or remote browser, before declaring the migration done. | | Rollback boundary | Keep the previous app config, library database, and watch-state export until the new service has survived normal household use. | If one row in that table feels vague, that is the row to slow down on. Vague proof is usually a sign that the system is crossing a boundary: LAN to public internet, host filesystem to container mount, web UI to background worker, old disk to new pool, local client to remote client, or human memory to written runbook. A useful preflight does not need to be long. It needs to be specific enough that a second person, or your future self, can repeat it without guessing what you meant. For example, "check backups" is weak. "Restore one app database dump and one uploaded file into a temporary path, then open the app against it" is useful. "Domain works" is weak. "Curl the public route from outside the LAN, verify the certificate, and test the real callback path" is useful. ![ServerCompass showing a completed deployment dashboard after an app is running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *The deployment is only the first state to prove; the dashboard should lead into checks for data, access, rollback, and monitoring.* ## Keep the runbook small enough to use The best runbook for a small self-hosted service is usually one page. It should include the service purpose, the data locations, the update command, the backup location, the restore sample, the public URL or private access method, the expected health check, and the rollback stop point. Anything longer tends to become documentation theatre. Anything shorter tends to skip the part you will need during the incident. When the setup uses Docker Compose, keep the compose file, environment variables, and volume map together. When it uses a NAS or hypervisor, keep the storage ownership decision explicit. When it uses a reverse proxy, record which host terminates TLS and which app receives the upstream request. When it uses a tunnel or VPN, record whether the service is meant to be public, private, or split by route. This is also where product selection becomes less emotional. You can compare tools by whether they make the proof easier. A tool that gives you logs, restart history, a clear volume map, and a rollback path may be better for a small operator than a more flexible platform that hides those basics behind extra layers. ## Where ServerCompass fits [ServerCompass](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) is useful when the work is no longer just "install the app" and has become "keep the app deployable on a VPS." It gives you a repeatable place to choose templates, see what was deployed, and keep the operational surface visible enough to inspect. That does not replace backups, DNS checks, client testing, or a recovery plan. It gives those checks a clearer starting point. The practical move is to use a deployment tool for the repeatable part and keep the promise-specific proof in your own runbook. If this service matters, do not stop at a successful install screen. Prove the data path, prove the access path, prove the rollback path, and only then call it ready. ## Final checklist - Write the service promise in one sentence. - List the data, network, storage, and credential boundaries. - Run one outside-in access test and one inside-the-host health test. - Restore a small sample before the next major change. - Define the rollback trigger before you begin. - Keep screenshots, commands, and links close to the deployment record. That is enough structure for a small operator to move faster without turning every app into a platform project. More importantly, it turns vague confidence into evidence you can reuse the next time the same class of problem appears. --- ## The Smallest-Viable Production Runbook for a Web App Source: https://deploytovps.com/blog/guide/smallest-viable-production-runbook-web-app Published: 2026-06-24 Tags: deployment, dns, vps, postgres, self-hosting You were hired to write the frontend and inherited Docker, DNS, SSL, secrets, the database, and the pager. Here are the five questions every deployed web app has to answer — written down once so you stop rediscovering them during incidents. A pattern keeps surfacing in r/webdev: you took a job to write the frontend, and somewhere along the way you inherited the backend, the database, Docker, CI/CD, SSL, DNS, environment configs — and the pager for production issues. Nobody handed you a map. One developer described the exact chain: APIs, auth, uploads, caching, a database, then Docker, then a deploy target, then a TLS certificate that expired at the worst possible time. Another just needed to point a root domain at an app on a subdomain and couldn't find a plain-language route through it. A third had a self-hosted Postgres instance and a Vercel frontend, and was staring down per-project static-IP fees just to let the two talk securely. None of these are framework problems. They're operations problems, and the fix isn't a bigger platform — it's a *smallest-viable runbook*: the five questions every deployed web app has to answer, written down once so you stop rediscovering them during incidents. Here it is. ## 1. Where does the app actually run? Before anything else, be able to name the box. Not "Vercel" or "the cloud" in the abstract — the specific place a process is listening on a port. For a frontend dev pulled into ops, the honest default is a single small VPS running your app as one Docker stack. One machine you can SSH into, one `docker compose ps` that shows you everything, one place logs live. Managed platforms feel simpler until the bill, the cold starts, or the egress fees push back — which is exactly what happens when a [small Next.js app outgrows Vercel pricing](https://deploytovps.com/guide/small-nextjs-apps-vercel-to-vps-cost) and you start paying per-project for things a $5 box does for free. Write down, in your runbook: the host, the OS, how you connect (SSH key, not password), and the one command that lists every running service. If you can't produce that paragraph, you don't yet know where your app runs — and that's the first gap to close. ## 2. How does DNS prove it's reachable? DNS is where most frontend-to-ops developers lose a weekend, because the mental model is missing. Three records do almost all the work: - An **A record** points a name (`app.example.com`) at your server's IP. - A **CNAME** points one name at another name (useful for `www`). - The **apex/root** (`example.com` with no subdomain) is the awkward one — many registrars won't let you CNAME it, so you either use an A record to the same IP or a registrar-level redirect. That root-to-subdomain redirect — "I have a GoDaddy domain and a Hostinger VPS, how do I send `example.com` to `app.example.com`?" — is not a code change. It's either a redirect rule at the registrar/DNS provider, or a one-line server block in your reverse proxy that 301s the apex to the subdomain. Pick one, document which, and stop guessing. The proof step matters more than the config: after you change DNS, *verify it resolves* before you assume it's broken. `dig +short app.example.com` should return your server's IP. If it doesn't, you're waiting on propagation, not debugging your app. Half of "the site is down" panics are just DNS that hasn't caught up yet. ## 3. How are secrets stored? The moment you have a backend, you have secrets: database passwords, API keys, signing tokens. The rule is short — **secrets live in an environment file on the server, never in git, never in the frontend bundle.** Concretely: - A single `.env` (or `compose`-referenced env file) on the box, readable only by the deploy user. - `.env` in `.gitignore`, with a committed `.env.example` that lists the *names* of every variable and no values. - Anything the browser needs is public by definition — don't put a real secret behind a `NEXT_PUBLIC_` prefix and assume it's hidden. It ships to every visitor. This is also your disaster-recovery checklist: if the box died tonight, the `.env` is the one file (besides the database) you can't regenerate from git. Back it up off the machine. A deployment is only as reproducible as its [documented config and secrets handling](https://deploytovps.com/guide/deployment-docs-checklist-for-self-hostable-apps) — the rest is just `git clone`. ## 4. How is the database reached? This is the question that sent the Next.js-plus-self-hosted-Postgres developer in circles, and the answer reframes the whole cost problem. When your frontend lives on a serverless platform and your database lives on your own box, *every request needs a secure path between two networks you don't fully control* — which is why platforms start selling you static outbound IPs to allowlist, per project. The smallest-viable answer is to collapse the distance: run the app and the database on the same VPS, on a private Docker network, and never expose Postgres to the public internet at all. The app reaches the database at `db:5432` inside the compose network; the firewall only opens 80/443. No static IP to rent, no allowlist to maintain, no database port facing the world. If the two genuinely must live apart, then the path is: a firewall rule allowing only the app's IP, TLS on the database connection, and a real credential — never "open 5432 to 0.0.0.0/0 and hope." Write down which model you're using and what the connection string assumes. A surprising number of breaches are just a managed database left open with a default password. ## 5. What checks happen after deploy? A deploy isn't done when the command exits zero. It's done when you've *confirmed the new version is actually serving traffic correctly* — and this is the step that separates a runbook from a prayer. Your minimum post-deploy pass: - The app responds on its health endpoint (or just loads) over HTTPS, not just HTTP. - The TLS certificate is valid and not expiring this week. - The database migration that shipped with this deploy actually ran. - Logs show the new version booting clean, with no restart loop. You want one place that answers "is the stack healthy?" without four separate logins. A self-host dashboard like [ServerCompass](https://servercompass.app) gives you that single pane — services, deploy state, and health in one view — so the post-deploy check is a glance instead of an investigation. ![The ServerCompass "Your Apps" dashboard showing deployed services and their health at a glance — the single pane that turns a post-deploy check into a glance instead of four separate logins (ServerCompass).](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) And when a deploy *does* go wrong, the runbook's last line is the most important one: know how to undo it. Keep the previous image tagged, keep the last database dump, and rehearse the [rollback before you need it](https://deploytovps.com/guide/saas-avoidant-vps-launch-checklist) so "roll back" is a command you've run, not a theory. ## The runbook is five sentences You don't need a platform team. You need five answers written in a file in your repo: 1. **Runs:** a single VPS, app as a Docker stack, reached by SSH key. 2. **DNS:** A record to the box, apex 301s to the subdomain, verified with `dig`. 3. **Secrets:** one `.env` on the server, gitignored, backed up off-box. 4. **Database:** same box, private network, never public. 5. **After deploy:** HTTPS + cert + migrations + clean logs, on one dashboard, with a rehearsed rollback. That's the smallest viable production runbook. It fits on an index card, it answers the questions that actually page you at 2 a.m., and it scales further than most frontend-devs-turned-operators expect before they ever need to think about Kubernetes. When you outgrow the single box, the [step up to lightweight orchestration](https://deployhandbook.com/compare/lightweight-orchestration-vs-kubernetes-single-node) is a deliberate decision — not an accident you back into because nobody wrote the runbook down. --- ## A Pre-Drive-Swap Checklist for Home Servers: Know Your Boot Media and App-Data Before You Pull a Disk Source: https://deploytovps.com/blog/guide/home-server-drive-swap-boot-media-appdata-preflight Published: 2026-06-24 Tags: unraid, jellyfin, backup, boot-media, migration, home-server Swapping a disk in a home server should be a ten-minute job. It turns into a weekend outage when you pull the wrong drive, lose an appdata volume, or break the array because you never mapped which disk does what. Here is the preflight to run before you touch the hardware. Swapping a disk in a home server sounds trivial: shut down, pull the old drive, slot the new one, boot back up. For a lot of self-hosters it is trivial — right up until the box does not come back, Jellyfin has forgotten its entire library, or the array reports a disk as missing and refuses to mount. The drive swap was never the risky part. The risky part is that most home servers blur three very different storage roles together, and you cannot safely pull a disk until you know which role each one plays. This is a preflight checklist for *planned* drive changes — upgrading capacity, replacing an aging disk, or migrating to an SSD. If your drive has already failed and you are in recovery mode, read the [self-hosted recovery playbook](https://deploytovps.com/guide/self-hosted-recovery-plan-freezes-lockouts-boot-media) instead; this guide is about not getting there in the first place. ## Map Your Three Storage Roles First Before you open the case, write down which physical device holds each of these: - **Boot media** — the OS itself. On Unraid this is the USB flash drive. On Proxmox or bare Linux it is the disk holding `/` and `/boot`. Lose it and the machine will not start, but your data is usually fine. - **App-data** — the small, fragile, high-value state: container configs, databases, Immich and Jellyfin metadata, `*.db` files. On Unraid this lives at `/mnt/user/appdata`; with Docker it is your named volumes. This is the part people forget to back up because it is small. - **Bulk data** — the media library, photos, downloads. Large, replaceable-ish, and usually what people *think* of as "my data." A drive swap is dangerous when one physical disk quietly holds two of these roles. The classic trap is appdata sitting on the same disk you are about to pull to "just add more media space." ```bash # See every disk, its size, mountpoint, and model in one view lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,MODEL,SERIAL # Find what is actually mounted where findmnt -t ext4,xfs,zfs,btrfs # On Unraid, confirm the flash device and appdata share location ls -la /boot # the USB flash (Unraid OS) du -sh /mnt/user/appdata/* ``` Match every serial number to a physical bay before you power down. A photo of the open case with drives labeled by serial is worth more than any spreadsheet at 11pm. ## Confirm the Boot Media Is Healthy — and Backed Up USB boot media wears out, and a forced power cycle during a swap is exactly the kind of event that corrupts it. On Unraid, take a flash backup from **Main → Boot Device → Flash → Flash Backup** (or copy the entire USB contents elsewhere). That backup is what lets you re-license and rebuild onto a fresh stick if the old one dies during the shuffle. For an SSD or HDD boot disk, check SMART health before you rely on it surviving a reboot: ```bash smartctl -H /dev/sdX smartctl -A /dev/sdX | grep -Ei 'reallocated|pending|uncorrect' ``` If the boot device is the thing you are replacing, make sure you have a way to reinstall the OS and re-apply config — not just a vague memory of how you set it up the first time. ## Back Up App-Data the Right Way (Not Just the Files) Copying a running database directory gives you a backup that may not restore. Stop the services first so the on-disk state is consistent: ```bash # Docker / Compose docker compose stop tar czf /mnt/backups/appdata-$(date +%F).tar.gz -C /mnt/user appdata docker compose start ``` On Unraid, the **CA Appdata Backup** plugin does the stop → archive → start dance for you on a schedule. The point is the same: appdata is the part that does not regenerate. Bulk media can be re-ripped or re-downloaded; a corrupted Immich Postgres volume cannot. Treat a restore drill as part of the swap, not an afterthought — the [homelab restore-drill guide](https://deploytovps.com/guide/homelab-restore-drills-permissions-service-state) walks through verifying the backup actually comes back with the right permissions. ## Understand Your Array Before You Remove a Disk This is where parity-based systems bite people. The rules differ by storage layer: - **Unraid array:** removing a *data* disk invalidates parity unless you follow the "remove disk" procedure and run a New Config + parity rebuild. Pulling a disk and booting will show it as missing and start emulating it from parity — fine briefly, dangerous if a second disk hiccups during the rebuild. Never remove two disks at once. - **ZFS:** replacement is a `zpool replace` followed by a resilver. The pool stays degraded — and at reduced redundancy — until the resilver finishes. Do not pull a second disk mid-resilver. - **mdadm / btrfs raid:** similar story — `mdadm --fail`/`--remove`/`--add`, then a rebuild window where you are exposed. Check the current state is *clean* before you start. Swapping a disk while the array is already degraded is how single failures become data loss: ```bash zpool status # ZFS: want "state: ONLINE", no resilver in progress cat /proc/mdstat # mdadm: want [UU], not [U_] ``` ## Do the Swap With a Rollback Path Keep the old disk intact and untouched until the new one is verified. That single habit turns "I think I lost the array" into "put the old disk back and try again." Concretely: 1. Note the new disk's serial and target bay. 2. Power down cleanly — never hot-pull an array disk unless the hardware and filesystem explicitly support it. 3. Install the new disk; do **not** wipe or reuse the old one yet. 4. Boot, then run the proper add/replace procedure for your array type. 5. Let parity rebuild or the pool resilver fully complete before declaring victory. ## Verify, Then Restore-Test After the rebuild finishes, confirm the things that actually matter to you — not just that the box booted: ```bash zpool status # ONLINE, no errors, resilver done docker compose ps # every service Up ``` Open Jellyfin and Immich and confirm libraries, users, and metadata are present. Then prove the appdata backup you took at the start restores into a throwaway location. A backup you have never restored is a hypothesis, not a safety net. A planned drive swap is genuinely low-risk once you have mapped boot media, appdata, and bulk data to physical devices, confirmed the array is clean, and kept the old disk as your undo button. The outage stories almost always trace back to skipping one of those four steps. If you want the broader "what to do when it has already gone wrong" version, pair this with the [recovery playbook](https://deploytovps.com/guide/self-hosted-recovery-plan-freezes-lockouts-boot-media) and the [Unraid upgrade preflight](https://deploytovps.com/guide/unraid-73-upgrade-recovery-preflight-webui-lockups). --- ## Self-Hosted n8n: The OAuth Callback URL Checklist Before Your Credentials Fail Source: https://deploytovps.com/blog/guide/n8n-oauth-callback-url-checklist-docker-tunnels Published: 2026-06-24 Tags: n8n, docker, oauth, webhooks, vps, self-hosted Your self-hosted n8n container is running, you add a Google credential, and OAuth dies with redirect_uri_mismatch. It is almost never the credential — it is the callback URL n8n hands the provider, and the env vars and reverse-proxy headers that decide what that URL says. You followed the install guide, the n8n container is up, and the editor loads. Then you add your first OAuth2 credential — Google, Microsoft, whatever — click *Connect*, and the provider throws `redirect_uri_mismatch` or sends you back to a `http://localhost` page that goes nowhere. Nine times out of ten the credential is fine. The problem is the **OAuth callback URL**: the public address n8n tells the provider to redirect back to after login. Self-hosting behind Docker, a reverse proxy, and sometimes a tunnel makes it very easy for n8n to advertise the wrong one. This is the checklist to get that callback URL correct and keep it correct. It assumes you have a working n8n install already; if you are starting from zero, the [self-host n8n guide](https://deploytovps.com/self-host/n8n) covers the base setup and the [production-readiness checklist](https://deploytovps.com/guide/n8n-self-host-production-readiness-checklist) covers everything around it. ## How n8n Builds the Callback URL When you open an OAuth2 credential, n8n displays an **OAuth Redirect URL** that looks like: ``` https://n8n.yourdomain.com/rest/oauth2-credential/callback ``` That URL is not magic — n8n constructs it from environment variables. If those variables are wrong, n8n builds a callback pointing at `http://localhost:5678`, the provider rejects it, and OAuth fails before it starts. The variables that decide what the URL says: - `WEBHOOK_URL` — the public base URL n8n uses for webhooks **and** OAuth callbacks. This is the one that matters most. - `N8N_EDITOR_BASE_URL` — the public URL of the editor; set it to the same value. - `N8N_HOST`, `N8N_PROTOCOL`, `N8N_PORT` — the older, more granular way n8n derived its address. Still respected, still a source of `http`-vs-`https` mismatches. ## Set the Public URL Explicitly Do not let n8n guess its own address. Pin it to the exact public HTTPS URL your users hit. In Docker Compose: ```yaml services: n8n: image: docker.n8n.io/n8nio/n8n environment: - N8N_HOST=n8n.yourdomain.com - N8N_PROTOCOL=https - N8N_PORT=5678 - WEBHOOK_URL=https://n8n.yourdomain.com/ - N8N_EDITOR_BASE_URL=https://n8n.yourdomain.com/ ports: - "127.0.0.1:5678:5678" ``` Two details people miss: `WEBHOOK_URL` should end with a trailing slash and use **https**, and the container port binding can stay on localhost because your reverse proxy is what faces the internet. After changing these, **recreate** the container (`docker compose up -d --force-recreate`) — n8n reads them at startup, so a plain restart of an old container will not pick them up. ## Register the Exact Redirect URI With the Provider Open the credential in n8n and copy the **OAuth Redirect URL** it shows verbatim. Then paste that exact string into the provider's authorized redirect list — for Google, that is **APIs & Services → Credentials → your OAuth client → Authorized redirect URIs**. ``` https://n8n.yourdomain.com/rest/oauth2-credential/callback ``` The match must be exact: scheme, host, path, and no trailing slash on the callback path. `http` vs `https`, a `www.` that is present on one side, or a stray trailing slash will all produce `redirect_uri_mismatch`. Copy-paste it; do not retype it. ## Forward the Right Headers at the Reverse Proxy If n8n sits behind nginx, Traefik, or Caddy, the proxy must tell n8n that the original request was HTTPS. Without `X-Forwarded-Proto`, n8n can decide it is serving plain `http` and build an `http://` callback even though users arrive over TLS. An nginx location block that gets this right: ```nginx location / { proxy_pass http://127.0.0.1:5678; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # n8n editor uses websockets proxy_set_header Connection "upgrade"; } ``` The `Host` and `X-Forwarded-Proto` headers are the two that directly affect the callback URL. The websocket upgrade headers are not about OAuth, but without them the editor UI misbehaves, so set them while you are here. ## Tunnels: cloudflared, Tailscale, and ngrok Tunnels add one more place for the URL to drift. The rule is the same: whatever public hostname the tunnel exposes is what `WEBHOOK_URL` and the provider's redirect URI must say. - **Cloudflare Tunnel:** point `WEBHOOK_URL` at the stable `https://n8n.yourdomain.com` hostname mapped in the tunnel config — not an internal address. Cloudflare terminates TLS, so n8n still needs `X-Forwarded-Proto: https` (Cloudflare sets it). - **ngrok:** a free random `*.ngrok-free.app` URL changes every restart, which breaks OAuth every time. Use a reserved domain, or do not use ngrok for credentials you intend to keep. - **Tailscale / `N8N_PROTOCOL`:** a tailnet MagicDNS name works, but the provider must allow that exact redirect URI, and many OAuth providers will not accept non-public TLDs. For real OAuth, front n8n with a public HTTPS hostname. n8n also ships a built-in `n8n.cloud`-style tunnel for local testing (`--tunnel`). It is for quick experiments only — never for credentials you want to survive a restart. ## Verify End to End After the changes, confirm the externally visible URL matches what n8n thinks it is: ```bash # Does the public URL resolve to your n8n over HTTPS? curl -sI https://n8n.yourdomain.com/healthz | head -1 # What callback is n8n actually advertising? Read it from the credential UI — # it must equal the URI registered with the provider, character for character. ``` Then re-run the OAuth connect flow. If it still fails, the mismatch is almost always one of three things: `WEBHOOK_URL` not actually applied (you restarted instead of recreating the container), the reverse proxy not sending `X-Forwarded-Proto`, or a redirect URI in the provider that differs by a trailing slash or scheme. Get the callback URL right once — pinned in `WEBHOOK_URL`, forwarded honestly by the proxy, and registered exactly with the provider — and OAuth credentials stop being the flaky part of your automations. Everything else in n8n assumes that public URL is correct, so it is worth the ten minutes before you wire up a dozen integrations on top of it. --- ## Your First Proxmox VM Can Wait: Get DNS, DHCP, Console Access, and Storage Right First Source: https://deploytovps.com/blog/guide/proxmox-first-vm-preflight-dns-dhcp-rescue-storage Published: 2026-06-24 Tags: proxmox, dns, dhcp, storage, recovery, beginner Most first-time Proxmox pain has nothing to do with VMs. It is a host that cannot resolve names, an IP that moves on the next reboot, no way back in when the web UI is down, and VM disks dumped on the wrong storage. Fix those four layers before you create a single VM. The Proxmox install finishes, you log into the web UI, and the obvious next move is to create a VM. Resist it for fifteen minutes. The failures new Proxmox users hit are almost never in the VM — they are one layer down, in the host's networking, name resolution, recovery access, and storage layout. Get those right while the node is empty and cheap to change, and your first VM is boring. Skip them and you will be rebuilding from scratch after the first reboot moves your IP or the web UI locks you out. This is a preflight for a *brand-new single node*. If you are already running bonded links and a backup server and chasing path-specific reliability problems, the [Proxmox network-path and recovery checklist](https://deploytovps.com/guide/proxmox-network-path-recovery-checks-preflight) is the operator-grade version. This guide is the layer before that. ## Fix Host DNS and Time Before Anything Else A Proxmox node that cannot resolve hostnames will fail in confusing ways: `apt update` hangs, no-subscription repos error out, and cluster joins time out. Confirm the host can actually reach DNS and that its own FQDN resolves: ```bash cat /etc/resolv.conf # is there a reachable nameserver? hostname --fqdn # should print pve.yourdomain.lan, not an error getent hosts $(hostname) # the node must resolve its own name ping -c1 deb.debian.org # name resolution + egress in one shot ``` The node's name and IP must be consistent across `/etc/hosts` and `/etc/hostname`. Proxmox writes the node identity into its cluster config from this, and a mismatch is painful to fix later. Time matters just as much. Skewed clocks break TLS, certificate validation, and any future cluster quorum. Confirm NTP is actually syncing: ```bash timedatectl # want: System clock synchronized: yes chronyc tracking 2>/dev/null # if chrony is installed ``` ## Give the Node a Stable Address The single most common first-week regret is a management IP that came from DHCP and then moved. Your bookmarked `https://192.168.1.50:8006` stops working, the node's cluster identity gets confused, and backups pointed at the old address fail silently. Pick one of two clean options and commit to it: - **Static IP on the host** — set it directly in `/etc/network/interfaces` on `vmbr0`. This is the most predictable for a server. - **DHCP reservation** — bind the NIC's MAC to a fixed address in your router so the node always gets the same IP. Fine, as long as the reservation is real and not just a current lease. ```bash ip -br a # current addresses per interface cat /etc/network/interfaces # confirm vmbr0 has the address you expect ``` Whatever you choose, write down the management IP, the gateway, and the VLAN if you use one. A moving management plane undermines everything built on top of it. ## Make Sure You Can Get Back In Before You Need To The question that separates a minor hiccup from a 2am drive to the basement: *if the web UI is unreachable, how do I administer this box?* Answer it now, while everything works. - **Web console (noVNC/xterm.js):** confirm it actually opens from the Proxmox UI — some browsers and reverse proxies block the websocket. - **SSH:** verify key-based root SSH works from your normal workstation. - **Out-of-band:** if the hardware has IPMI/iDRAC/iLO, log into it once and save the address and credentials. No BMC? A [PiKVM](https://deploytovps.com/guide/proxmox-network-path-recovery-checks-preflight) or even a cheap HDMI-over-IP gives you console access when the OS is wedged. - **Rescue boot:** know that you can boot the Proxmox ISO in *Rescue/Debug* mode or via GRUB to repair a host that will not start. ```bash # From your workstation, prove you can reach the node two independent ways ssh root@PVE_IP 'pveversion' curl -k -sI https://PVE_IP:8006 | head -1 ``` If the only way you can manage the node is the web UI, you have a single point of failure for administration itself. ## Lay Out Storage on Purpose Proxmox's default storage names confuse newcomers, and the confusion shows up as "no space" errors or VMs dumped on the tiny root volume. Know what each one is for: - **`local` (directory):** holds ISO images, container templates, and backups. Upload your install ISOs here. - **`local-lvm` (LVM-thin):** holds VM disk images and container root filesystems. This is where guests actually live. - **ZFS / dedicated pool:** if you installed on ZFS or added disks, decide explicitly where VM disks go rather than letting everything pile onto the root disk. Two gotchas to plan around: LVM-thin **over-provisions**, so the sum of your VM disks can exceed the pool — and when the thin pool fills, guests get read-only or corrupt. And do not let backups accumulate on `local` until they crowd out the OS. Set a sane retention. ```bash pvesm status # every storage, its type, and free space vgs ; lvs # confirm the thin pool size and usage ``` ## The Five-Line Pre-First-VM Checklist Before you click **Create VM**, all five of these should be true: 1. The host resolves names and its own FQDN, and the clock is synced. 2. The management IP is static or reserved and written down. 3. You can reach the node by SSH *and* console, plus a known rescue path. 4. ISOs live on `local`; you know which storage VM disks will use and that it has room. 5. You have confirmed there is a backup target (even a directory) before guests hold real data — see the [full-host disaster-recovery plan](https://deploytovps.com/guide/proxmox-full-host-disaster-recovery-plan). None of this is glamorous, and that is the point. The fifteen minutes you spend on DNS, addressing, recovery access, and storage layout is what lets your first VM — and the twenty after it — be uneventful. When you are ready to decide *what* to run on the box, the [first-Proxmox-server planning guide](https://deploytovps.com/guide/proxmox-first-server-planning-bundle) covers bundling media, photos, and backups without overloading a single node. --- ## If You Ship a Self-Hostable App, Your Deployment Docs Need This Checklist Source: https://deploytovps.com/blog/guide/deployment-docs-checklist-for-self-hostable-apps Published: 2026-06-23 Tags: self-hosted-apps, docker, deployment-docs, ssl, rollback Maintainers ship great Docker images and then watch users break on the first production install — SSL, env files, backups, upgrades, rollback. A deployment-docs checklist that survives real installs. If you maintain a self-hostable app, your `docker-compose.yml` is the easy part. The recurring way users bounce off — and flood your issue tracker — is the **first production install**: they get the container running, then hit SSL, env files, persistence, backups, and upgrades with no map. Good deployment docs are a feature, and they follow a predictable checklist. Write to it and your support load drops while your adoption rises. ## Docs that survive real installs cover the lifecycle, not just `up` A `docker run` one-liner gets someone to a demo. Production needs the whole lifecycle documented. Here's what users actually get stuck on, in order. ## 1. Persistence: what must survive a recreate State the named volumes/paths that hold real data, explicitly. Users *will* `docker compose down` and recreate; if it's not clear what's persistent, they lose data and blame the app. Spell out which directories are the database, the uploads, the config — and that bind-mounts must be owned by the container user (the [ownership trap](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight)). ## 2. Env and secrets: every required variable, and safe defaults - List **every** required env var, with an example and whether it's secret. - Never let the app boot to an insecure default (blank admin password) silently — fail loudly or force a value. - Document `.env` handling so secrets don't end up in logs or images. ## 3. SSL/reverse proxy: the part everyone gets wrong Most "it won't load on my domain" issues are reverse-proxy details. Document: - The proxy headers your app needs (`Host`, `X-Forwarded-Proto`, WebSocket upgrade if applicable) — the exact class of [reverse-proxy edge cases](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases) that bite real apps. - Any body-size or timeout requirements (uploads, long requests). - That TLS renewal must **reload** the proxy — the [renew-and-reload trap](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). ## 4. Backups: tell users what to back up and how to restore - Name the exact things to back up (database dump command + data dir) — not "back up your data." - Provide the **restore** procedure, and tell them to [test it once](https://deploytovps.com/guide/docker-volume-restore-drill). A backup doc without a restore doc is half a doc. ## 5. Upgrades and rollback: the supportable path - Document the upgrade steps and whether migrations are **forward-only** (so users don't try to downgrade into a newer schema). - Pin versions in examples and explain how to choose a version — [test the exact image](https://deploytovps.com/guide/test-exact-docker-image-before-rollout), don't tell people to run `:latest`. - Give a **rollback** path: which version to return to and how to restore data. This is what makes your app *supportable* rather than a one-way door. ## The maintainer's deployment-docs checklist - [ ] Persistent volumes/paths named explicitly, with ownership notes. - [ ] Every env var documented; no silent insecure defaults. - [ ] Reverse-proxy headers, body-size/timeout, and TLS-reload documented. - [ ] Exact backup *and* restore procedures. - [ ] Upgrade steps, migration direction, version pinning, and rollback. This is the same [launch checklist](https://deploytovps.com/guide/saas-avoidant-vps-launch-checklist) your users follow — meeting them halfway in the docs is the highest-leverage thing a maintainer can do. ## Takeaway The gap between "runs on my machine" and "users self-host it successfully" is documentation of the lifecycle: persistence, env, SSL, backups, and a rollback-able upgrade path. Write your deploy docs to that checklist and you convert frustrated first-install issues into successful, low-support deployments. --- ## Beyond Uptime: Monitoring the Real World — UPS, Floods, and Camera Streams Source: https://deploytovps.com/blog/guide/homelab-monitoring-real-world-events-ups-floods-cameras Published: 2026-06-23 Tags: monitoring, homelab, ups, home-assistant, alerts Your homelab can watch more than its own services. People use it to catch basement floods, UPS failures, and front-door events. How to add real-world monitoring without overbuilding a NOC. Most homelab monitoring watches services — is the site up, is the disk full. But the box is also perfectly placed to watch the *physical* world it sits in: a UPS that just lost mains power, a sensor in a basement that's getting wet, a camera at the front door. Operators are increasingly wiring these in, and the trick is doing it usefully without overbuilding a mini network operations center for a house. Here's a practical, LAN/VPN-first approach. ## Start with the events that actually matter Don't instrument everything; instrument what would *hurt* to miss: - **Power**: a UPS on battery means you have minutes — the highest-value signal, because it can trigger a clean shutdown before data loss. - **Water/temperature**: a leak near the server or a failing AC/fan is cheap to detect and expensive to ignore. - **Physical access/events**: a doorbell or camera you already have. Three sensors that matter beat fifty dashboards nobody reads — the same restraint as [low-noise uptime checks](https://deployhandbook.com/best/low-noise-uptime-checks-homelab). ## UPS: monitor it *and* act on it A UPS you can't see is just a delay. Wire it up so the server reacts: - Use NUT (Network UPS Tools) or your UPS's daemon so the host **knows** when it's on battery. - Configure a **clean shutdown** at a low battery threshold — the single most valuable real-world automation, because it turns a power cut from a corruption risk into a graceful stop. This is the physical-world version of the [graceful-drain discipline](https://deploytovps.com/guide/websocket-checks-before-rolling-realtime-deploys). - Alert when it goes on battery, not just when it dies. ## Sensors and cameras: keep it LAN/VPN-first Environmental sensors and cameras are exactly the data you *don't* want on the public internet: - Keep streams and sensors on the **LAN**, reachable remotely only over a [VPN/overlay](https://deploytovps.com/guide/remote-self-host-fleet-access-beyond-simple-tunnels) — never a port forwarded straight to a camera. - A hub like Home Assistant ties sensors, cameras, and UPS state into one place with automations and alerts, without you writing a monitoring stack from scratch. ![ServerCompass selecting the Home Assistant template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/home-assistant/04-select-home-assistant-template.png) *Home Assistant deployed from a template in [ServerCompass](https://servercompass.app) — a single hub for UPS state, environmental sensors, and camera events, kept on the private network.* ## Alerts: severity and quiet hours, or you'll mute it Real-world alerts get ignored the same way service alerts do — through noise. Apply the same discipline: - **Severity routing**: "UPS on battery" pages you; "motion at the door" goes to a quiet channel — the [alerting last-mile](https://deploytovps.com/guide/alerting-last-mile-uptime-kuma-n8n-pager). - **Quiet hours** for non-critical events so the system doesn't cry wolf at 3am over a passing cat. - Context in every alert: what, where, how urgent. ![ServerCompass severity-based alert routing](https://assets.stoicsoft.com/posts/servercompass/alert-routing-discord-telegram-severity-templates.png) *Severity-routed alerts in [ServerCompass](https://servercompass.app) — a UPS-on-battery page and a doorbell notification go to different places, so the urgent ones stay urgent.* ## Don't overbuild You don't need Prometheus, Grafana, and a camera NVR to know your basement is flooding. Start with the three events that matter, react to power automatically, keep it on the private network, and route alerts by severity. Add more only when a real gap appears — the [observability-without-overbuilding](https://deploytovps.com/guide/observability-migration-without-query-bills) mindset. ## Takeaway Your homelab is a sensor platform you already own. Watch the physical events that would actually hurt to miss — power first, then water and access — make the UPS trigger a clean shutdown, keep cameras and sensors LAN/VPN-only, and route alerts by severity. That's real-world monitoring that earns its keep without becoming a second job. --- ## Immich Post-Install Diagnostics: Volume Mounts, Sidecars, Timezones, and the Database Boundary Source: https://deploytovps.com/blog/guide/immich-post-install-volume-database-diagnostics Published: 2026-06-23 Tags: immich, docker, postgres, volumes, photo-backup Immich is installed but misbehaving — the ML sidecar can't see your photos, timestamps are off by hours, an upgrade complains about the database. A diagnostics checklist for the volume and DB boundary. Immich's hard part isn't installing it — it's the cluster of small failures that show up *after* install, all rooted in the same two boundaries: how Docker volumes are mounted, and where the database's assumptions meet your storage. The machine-learning sidecar "can't find" your library, photo timestamps land hours off, an upgrade complains about the database. None of these are mysterious once you know they all live at the volume/DB boundary. Here's the diagnostics checklist. ## Symptom 1: a sidecar/container can't see mounted folders Immich runs multiple containers (server, ML, microservices) that must share the *same* view of your photos. When the ML sidecar "can't see" the library, it's almost always a mount mismatch: - Every Immich container that touches media must mount the library at the **same path**. If the server sees `/usr/src/app/upload` but the ML container mounts something else, they disagree. - Bind-mount **ownership** must match the container user, or it reads an empty/forbidden directory — the [host-directory-ownership trap](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight). - On NAS/network mounts, confirm the share is actually mounted on the host before Docker starts, or the container binds an empty path. Keep app state local and bulk media deliberate — the [storage split](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker). ## Symptom 2: timestamps are wrong by hours Photos showing up on the wrong day is a timezone boundary problem: - Set the container's `TZ` to your actual timezone; without it, Immich interprets times in UTC and your "midnight" photos jump a day. - EXIF vs filesystem time: Immich prefers embedded EXIF, but files lacking it fall back to filesystem mtime — which a careless copy can rewrite. Preserve timestamps when you move files (the [data-migration discipline](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state)). ## Symptom 3: upgrades expose database/storage assumptions When an upgrade suddenly complains, it's the DB boundary surfacing: - Immich runs **forward-only migrations**; an interrupted or mismatched-version start can wedge it. Only ever move forward, and back up the database first — the [upgrade-as-restore-problem](https://deploytovps.com/guide/immich-upgrade-database-restore-drill). - The Postgres volume must persist across restarts and use the Immich-specified image/extensions; a generic Postgres or a lost volume breaks it. - Database and library must be **consistent with each other** — restore them together, never one without the other. ## The boundary checklist - [ ] All media-touching containers mount the library at the *same* path, owned by the container user. - [ ] Network shares mounted on the host before Docker starts. - [ ] `TZ` set correctly; timestamps preserved on any file move. - [ ] Postgres on a persistent volume with the required image; DB + library backed up and restored together. - [ ] Upgrades go forward only, after a database dump. Deploying Immich from a template avoids most of these by wiring the volumes, database, and environment consistently from the start: ![ServerCompass selecting the Immich template before deploy](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *A template deploy in [ServerCompass](https://servercompass.app) provisions Immich's containers, shared volumes, and Postgres together — so the post-install boundary issues above mostly never appear.* ## Takeaway Every confusing Immich post-install symptom traces to one of two boundaries: the volume mount (paths, ownership, network timing) or the database (timezone, persistence, forward-only migrations). Check those, keep the DB and library consistent, and Immich settles down — the app was fine; the boundaries needed minding. --- ## Before You Upgrade a Home Server, Make Sure You Can Still Get Back In Source: https://deploytovps.com/blog/guide/pre-upgrade-network-boot-preflight-remote-access Published: 2026-06-23 Tags: proxmox, networking, upgrade-preflight, recovery, reliability A small network or bootloader mistake during an upgrade can lock you out before the app layer even matters — and a remote box is hard to fix when you can't reach it. A pre-upgrade access preflight. The scariest server failures aren't in the app layer — they're the ones that make the box *unreachable* before you even get to the app. A network tweak that drops your management connection, a bootloader that doesn't come back after a kernel update, an LACP bond that renegotiates wrong: any of these can lock you out of a remote home server mid-upgrade, and now you're debugging something you can't reach. The fix is a pre-upgrade preflight focused on one question: *if this upgrade goes wrong, can I still get back in?* ## Why upgrades are the dangerous moment Upgrades touch the layers *below* your apps — the kernel, the bootloader, the network stack. Those are exactly the layers that, if broken, take away your ability to fix them remotely. An app crash you can SSH in and fix; a host that won't boot or won't network, you can't. So the preflight isn't about the upgrade succeeding — it's about preserving access if it doesn't. ## Preflight 1: a second way in that doesn't depend on the upgrade Never upgrade a remote box with only one path to it: - **Out-of-band access** — IPMI/iKVM, a provider's serial/VNC console, or a separate management NIC — that works even if the OS network is down. This is the single highest-value safeguard, the same lesson as [router-in-a-VM out-of-band access](https://deploytovps.com/guide/router-in-a-vm-homelab-failure-modes). - If you have no OOB at all, you're gambling that nothing network-or-boot-related breaks. For a remote box, that's a bad bet. ## Preflight 2: snapshot/rollback before you start You should be able to undo the upgrade without physical access: - A **filesystem/VM snapshot** (or a Proxmox/PBS backup) taken immediately before, so rollback is "restore," not "rebuild" — the [full-host recovery](https://deploytovps.com/guide/proxmox-full-host-disaster-recovery-plan) approach. - For boot-critical changes, know how to select the **previous kernel** at the bootloader, and confirm the bootloader menu is actually reachable (timeout not zero). ## Preflight 3: protect the network path Network config is where remote upgrades most often self-sabotage: - **LACP/bonding**: a bond that renegotiates after an upgrade can drop the link. Know the fallback and avoid changing bond config in the same maintenance as a major upgrade. - **Don't change the management IP/VLAN and upgrade at once** — one variable at a time. If you must change networking, do it with OOB access ready. - Keep firewall rules from locking out your own management port mid-change. ## Preflight 4: sequence and verify - Upgrade during a window where you can physically reach the box *if* OOB fails. - After the upgrade, verify **network and reachability first**, then the boot is clean, *then* the apps — bottom-up, the same order as [OS-EOL migrations](https://deploytovps.com/guide/os-eol-migration-multi-app-vps). ## Pre-upgrade checklist - [ ] Out-of-band access confirmed working *before* starting. - [ ] Snapshot/backup taken; rollback path known. - [ ] Previous kernel selectable; bootloader menu reachable. - [ ] No simultaneous network (IP/VLAN/LACP) changes with the upgrade. - [ ] Firewall won't lock out the management port. - [ ] Post-upgrade: verify network → boot → apps, in that order. ## Takeaway An upgrade that breaks the network or the bootloader is worse than one that breaks an app, because you can't reach the box to fix it. Before you upgrade a remote home server, guarantee a second way in, take a rollback snapshot, leave networking alone, and verify access first. Make "can I still get back in?" the question you answer *before* you type the upgrade command. --- ## An Arr-Stack Deployment Map: Where Sonarr, Downloaders, and Jellyfin Should Live Source: https://deploytovps.com/blog/guide/arr-stack-deployment-map-lxc-docker-bare-metal Published: 2026-06-23 Tags: arr-stack, sonarr, jellyfin, docker, deployment-planning The arr stack breaks for beginners before the first deploy — nobody tells them where the torrent client, bind mounts, and media server should actually live. A deployment map that keeps paths consistent. The "arr" stack — Sonarr, Radarr, Prowlarr, a download client, and a media server like Jellyfin — has a reputation for being brittle. It usually isn't the apps; it's that beginners deploy them with no **map**. They put each piece somewhere different, give every container its own idea of where media lives, and then spend a weekend fighting permission errors and files that won't move. Decide the deployment map first — where each service lives and, crucially, how the paths line up — and the arr stack becomes boringly reliable. ## The one rule that prevents most arr-stack pain: consistent paths The single biggest source of arr-stack misery is **inconsistent path mapping** between containers. If Sonarr sees the download at `/downloads` and Jellyfin sees the library at `/media`, but those map to different real folders on different filesystems, every "import" becomes a slow copy across filesystems — or fails on permissions. The fix: - Mount **one shared media root** into every arr container at the *same* path (e.g. `/data`), with `tv`, `movies`, and `downloads` underneath it. - Keep downloads and the library on the **same filesystem** so completed downloads move by **hardlink/atomic rename**, not a cross-disk copy. This is the difference between instant imports and a thrashing disk. Get this right and 80% of "the arr stack is broken" disappears. ## Where each piece should live **The arr apps (Sonarr, Radarr, Prowlarr, Bazarr).** Lightweight web services — ideal as Docker containers (or one LXC running Docker). Keep them together so they share the same media mount and network. Deploy them from templates so the config and volumes are consistent rather than hand-wired: ![ServerCompass selecting the Sonarr template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/sonarr/04-select-sonarr-template.png) *Deploying an arr app from a template in [ServerCompass](https://servercompass.app) — Sonarr, Radarr, and Prowlarr brought up with consistent volumes so the shared media path lines up across the stack.* **The download client (qBittorrent et al.).** Same host, same shared `/data` mount, so completed downloads hardlink into the library. If it runs through a VPN, pin it to the VPN's network namespace with a kill-switch — the [VPN/torrent isolation](https://deploytovps.com/guide/docker-socket-safe-management-boundaries) that keeps traffic from leaking. **The media server (Jellyfin/Plex).** Can live alongside the arr apps or in its own LXC for [GPU transcoding](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing) — but it must mount the *same* library path. Its deployment choices are their own decision: [Compose vs Portainer vs template](https://deploytovps.com/guide/jellyfin-deploy-method-compose-portainer-template). ## LXC vs Docker vs bare metal - **Docker (often inside one LXC on Proxmox)** — the common, flexible choice; everything shares a network and the media mount cleanly. - **Separate LXCs per app** — more isolation, but you must replicate the shared media mount into each, carefully, with matching UIDs. - **Bare metal** — rarely worth it for the arr stack; you lose the easy, reproducible config. For most people: one LXC running Docker Compose for the whole stack, one shared media volume, is the sweet spot. ## Permissions: one UID to rule them all The other classic failure is UID mismatch — Sonarr writes a file the download client or Jellyfin can't read. Run the stack with a **consistent PUID/PGID** that owns the media tree, so every app reads and writes as the same user. This is the [bind-mount ownership rule](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight) applied across a whole stack. ## Deployment map checklist - [ ] One shared media root mounted at the same path in every container. - [ ] Downloads + library on the same filesystem (hardlink imports). - [ ] Arr apps + downloader together; media server can split for GPU. - [ ] Consistent PUID/PGID owning the media tree. - [ ] VPN-bound downloader with a kill-switch if used. ## Takeaway The arr stack isn't fragile — undisciplined deployments are. Map it before you deploy: one shared media path across every container, downloads and library on one filesystem for hardlink imports, consistent permissions, and a clear home for each service. Draw that map first and the stack that everyone warns you about just quietly works. --- ## A Passthrough Preflight for Proxmox: IOMMU, Boot Order, and Hardware Isolation Source: https://deploytovps.com/blog/guide/proxmox-passthrough-iommu-boot-isolation-preflight Published: 2026-06-23 Tags: proxmox, gpu-passthrough, vfio, iommu, preflight Stacking GPU passthrough, an HBA to a TrueNAS VM, and LXC AI workloads on one Proxmox box works until a reboot reshuffles everything. A preflight for IOMMU groups, driver placement, and boot order. It's an ambitious and increasingly common Proxmox build: a GPU passed to one VM, the storage HBA passed to a TrueNAS VM, an LXC running an AI stack, and Docker workloads alongside — all on one box. It can work beautifully. It can also collapse on the next reboot when IOMMU groups shift, the host grabs a device you meant to pass through, or the boot order brings things up in the wrong sequence. The difference is a passthrough preflight: a stable failure model decided before apps depend on any of it. ## IOMMU groups decide what you can isolate Passthrough works at the granularity of **IOMMU groups** — you pass an entire group, not a single device. Before planning anything: - Enable IOMMU (Intel VT-d / AMD-Vi) in BIOS and the kernel cmdline. - List your groups and see what's actually separable. A GPU sharing a group with a controller you need on the host is a problem you want to find *now*, not after committing. - If a device is stuck in a crowded group, that's a motherboard/slot reality to design around — sometimes moving a card to another slot regroups it. ## Driver placement: bind passthrough devices to vfio early The host and a guest can't both own a device. The reliable pattern is to bind passthrough devices to `vfio-pci` **at boot, before the host's normal driver claims them**: - Blacklist the host driver for the GPU (e.g. the NVIDIA/AMD/`i915` driver) so the host never grabs it. - Bind the device IDs to `vfio-pci` via modprobe config. - Make it persistent — the number-one passthrough failure is a setup that works until reboot because the binding wasn't made permanent, the same [persistence trap as media GPU passthrough](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing). ## Boot order is part of the design When several VMs/containers depend on each other, the order they start matters: - A **storage VM** (TrueNAS with the passed-through HBA) must be up and exporting before VMs that mount its shares. - A [virtualized router](https://deploytovps.com/guide/router-in-a-vm-homelab-failure-modes) must be up before anything that needs the network. - Set VM **start/shutdown order and delays** in Proxmox so a cold boot reassembles the stack correctly instead of racing. ## Keep a clear ownership map The recurring danger with stacked passthrough is two things claiming one resource. Write down, explicitly: - Which device is owned by the host, which by each guest (vfio). - Which physical disks belong to TrueNAS (via the HBA) vs Proxmox — the [one-owner-per-disk rule](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups). Never half-virtualize a pool. ## Have a recovery path for when passthrough breaks a boot A bad vfio bind or a GPU the host now refuses to release can leave you without a console. Before you rely on it: - Keep a way in that *doesn't* need the passed-through GPU (serial console, SSH, iKVM). - Know how to boot with passthrough disabled to fix a misconfig. - Make sure this fits your [full-host disaster-recovery plan](https://deploytovps.com/guide/proxmox-full-host-disaster-recovery-plan) — a passthrough box is harder to rebuild blind. ## Passthrough preflight checklist - [ ] IOMMU enabled; groups listed and genuinely separable for what you're passing. - [ ] Passthrough devices bound to `vfio-pci` at boot; host driver blacklisted; persistent. - [ ] VM start order/delays set for storage → network → apps. - [ ] Explicit device + disk ownership map; no shared/half-virtualized pools. - [ ] Out-of-band access that survives the GPU being passed through. ## Takeaway Stacked passthrough on Proxmox fails on reboots, not on day one. Confirm your IOMMU groups isolate what you need, bind devices to vfio persistently, set a deliberate boot order, and keep a console that survives a bad bind. Build the failure model first and the powerful all-in-one box stays stable instead of becoming a reboot you're afraid to perform. --- ## Running Your Router in a VM? Check These Failure Modes Before Apps Depend on It Source: https://deploytovps.com/blog/guide/router-in-a-vm-homelab-failure-modes Published: 2026-06-23 Tags: proxmox, opnsense, networking, homelab, reliability Virtualizing OPNsense on the same Proxmox box that runs your apps is elegant until a host reboot takes the whole network — including your way back in — down with it. The failure modes to plan for. Virtualizing your router — OPNsense or pfSense as a VM on the same Proxmox box that runs everything else — is one of the most elegant homelab moves there is. It's also one of the most quietly dangerous, because it creates a **circular dependency**: the apps need the network, the network is a VM, the VM needs the host, and the host you manage *over* that same network. When it works, it's beautiful. When it reboots wrong, it can take down your entire network *and your way back in* at the same time. Plan for these failure modes before anything depends on the virtual router. ## The core risk: a circular dependency In a normal setup, a dedicated router is independent — the rest of the lab can reboot and the network stays up. Virtualize it and that independence is gone: a host reboot drops the router, which drops DHCP/DNS and routing, which can leave you unable to even reach the Proxmox UI to fix it. Every guardrail below exists to break or survive that loop. ## Failure mode 1: WAN NIC passthrough and a reboot The clean way to virtualize a router is to **pass a physical NIC through** to the router VM so it owns the WAN/LAN ports directly. But passthrough has the [persistence and IOMMU pitfalls](https://deploytovps.com/guide/proxmox-passthrough-iommu-boot-isolation-preflight) — if a reboot reshuffles the binding, the router VM comes up with no WAN. Confirm the NIC passthrough is persistent and the IOMMU group is clean *before* you rely on it. ## Failure mode 2: out-of-band access that doesn't need the router This is the guardrail that saves you from a 1am drive home. If the virtual router is down, you must still be able to reach the host: - A **separate management NIC** on a different network/switch, or a cheap secondary path, so the Proxmox UI is reachable without the virtual router. - Even better, **IPMI/iKVM** — hardware out-of-band that works even when the OS network is dead. Without this, a router-VM misconfig can lock you out of the very box you'd fix it from. ## Failure mode 3: boot order and DHCP/DNS recovery On a cold boot, the router VM must come up *first* and be healthy before anything that needs DHCP/DNS: - Set the router VM to **start first with priority and a delay**, so the network exists before apps race to use it — the [boot-order discipline](https://deploytovps.com/guide/proxmox-full-host-disaster-recovery-plan). - Have a **DHCP/DNS fallback** plan: if the router VM won't boot, can clients (and you) still get an address and resolve names? A tiny secondary DHCP or static fallback for the management path helps. ## Failure mode 4: backups and a tested rebuild of the router itself The router VM is now critical infrastructure — back it up like it: - Back up the router VM (and export its config) so you can restore it fast. - Rehearse restoring it as part of your [full-host disaster recovery](https://deploytovps.com/guide/proxmox-full-host-disaster-recovery-plan). - Keep a known-good config export off the box; rebuilding firewall rules from memory under an outage is miserable. ## Should you virtualize the router at all? It's a real trade-off. Virtualizing saves hardware and space and is genuinely elegant. A **dedicated physical router** (or a cheap mini-PC running OPNsense bare metal) removes the circular dependency entirely — the network stays up no matter what the lab does. If your homelab hosts things others depend on, the independence of a separate box is often worth the extra device. ## Failure-mode checklist - [ ] WAN/LAN NIC passthrough confirmed persistent across reboots. - [ ] Out-of-band access (separate mgmt NIC or IPMI) that doesn't need the router. - [ ] Router VM boots first with priority + delay; DHCP/DNS fallback exists. - [ ] Router VM backed up, config exported off-box, restore rehearsed. - [ ] Considered a dedicated router if others depend on the network. ## Takeaway A router in a VM is elegant until the circular dependency bites: a reboot takes the network and your access with it. Make NIC passthrough persistent, keep an out-of-band way in, boot the router first, and back its config up off the box — or sidestep the whole risk with a dedicated router. Plan the failure modes before your apps, and your family, depend on that VM. --- ## Lost HTTPS After a Network Change? A SWAG and Let's Encrypt Recovery Checklist Source: https://deploytovps.com/blog/guide/swag-letsencrypt-reverse-proxy-recovery-checklist Published: 2026-06-23 Tags: reverse-proxy, lets-encrypt, swag, immich, jellyfin Immich or Jellyfin loses HTTPS after an ISP change or a simple network move, and the SWAG logs are a wall of ACME errors. A recovery checklist that starts at DNS and ends at a renewed cert. It's a familiar homelab horror: everything worked, you changed *one* network thing — switched ISPs, your ISP rotated your IP, you moved the proxy to a new VLAN — and now Immich and Jellyfin throw certificate errors. The SWAG container's logs are a wall of ACME failures. Reverse proxies feel fragile in exactly these moments because HTTPS depends on a chain (DNS → reachability → ACME → cert → proxy) and a network change can quietly break any link. The recovery is methodical, not magic: walk the chain from the bottom up. ## Why a small network change breaks HTTPS The certificate didn't expire because the proxy is bad — it failed to *renew* because the renewal needs the same conditions the initial issuance did: a domain pointing at you, a reachable challenge path, and working container networking. Change your public IP or a port and any of those can silently lapse. This is the same chain the [Traefik-plus-Cloudflare edge](https://deploytovps.com/guide/traefik-migration-edge-cloudflare-routing) depends on; SWAG just surfaces it differently. ## Step 1: DNS — does the name still point at you? Start at the bottom. After an ISP change your public IP likely changed: - Check that your domain's A record points at your **current** public IP. If you use dynamic DNS, confirm the updater actually ran. - If you're behind [CGNAT now](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443) (some ISP changes drop you behind it), inbound DNS-to-port won't work at all — that changes the whole approach. ## Step 2: port reachability — can the challenge reach you? The HTTP-01 ACME challenge needs port 80 reachable from the internet: - Confirm the new router/ISP forwards 80 (and 443) to the proxy. An ISP that blocks 80 breaks HTTP-01 entirely. - If 80 is genuinely unavailable, switch SWAG to the **DNS-01 challenge** (validate via a DNS TXT record through your provider's API) — it doesn't need any inbound port and also enables wildcards. This is often the permanent fix after an ISP gets stricter. ## Step 3: read the ACME logs for the actual reason SWAG logs the real failure — read it instead of guessing: - "Connection refused"/timeout → reachability (Step 2). - "DNS problem"/NXDOMAIN → DNS (Step 1). - "too many certificates"/rate limit → you retried too hard; wait, then fix the root cause once. The logs name the broken link; the [read-the-log-first habit](https://deploytovps.com/guide/container-first-linux-troubleshooting-logs-process) applies here as much as anywhere. ## Step 4: container networking and the proxy itself If DNS and ports are fine, suspect the layer SWAG lives in: - The proxy container can reach the app containers (right Docker network, right names). - App-specific proxy settings are intact — the [Immich-behind-a-reverse-proxy edge cases](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases) (upload size, WebSocket upgrade, forwarded headers) often look like "HTTPS broke" when really the proxy→app hop changed. ## Step 5: force a clean renewal and verify Once the chain is repaired, force a renewal rather than waiting: - Trigger SWAG's renewal and watch the log for a success, not just a restart. - Confirm the cert's new dates and that the app loads over HTTPS on a real client (the mobile app, not just curl). - Make sure renewal **reloads** the proxy automatically going forward — the [renew-and-reload trap](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). ![ServerCompass DNS and SSL launch preflight checklist](https://assets.stoicsoft.com/posts/servercompass/vps-launch-preflight-checklist-dns-ssl.png) *Walking the DNS → reachability → SSL chain in [ServerCompass](https://servercompass.app) — the same ordered checklist that recovers a SWAG/Let's Encrypt setup after a network change.* ## Recovery checklist - [ ] DNS A record points at the *current* public IP (dynamic DNS updated). - [ ] Ports 80/443 forwarded; if 80 is blocked, switch to DNS-01. - [ ] ACME log read for the specific failure reason. - [ ] Proxy→app container networking and per-app settings intact. - [ ] Renewal forced, new cert verified on a real client, auto-reload confirmed. ## Takeaway HTTPS breaking after a network change isn't a fragile proxy — it's a broken link in the DNS → reachability → ACME → cert chain. Walk it from the bottom: fix the DNS, restore reachability (or switch to DNS-01), read the ACME log, check container networking, then force and verify a renewal. Methodical beats magic, and the chain comes back every time. --- ## Moving From Cloud to Homelab? Draw the Compute and Storage Boundaries First Source: https://deploytovps.com/blog/guide/cloud-to-homelab-migration-compute-storage-boundaries Published: 2026-06-23 Tags: homelab, cloud-migration, proxmox, storage, architecture Leaving rented cloud for self-hosted gear is exciting until you're staring at a NAS, a DAS, and a Proxmox box wondering what goes where. A decision tree for compute and storage boundaries before apps move home. Leaving rented cloud for hardware you own is one of self-hosting's great satisfactions — right up until the boxes arrive and you're standing over a NAS, a DAS enclosure, and a Proxmox mini-server with no plan for what runs where. People who skip this step end up rebuilding a month later when the database they put on the NAS corrupts or the Proxmox box runs out of room. Draw the compute and storage boundaries *before* apps move home, and the migration is a one-time setup instead of a recurring redo. ## Why the boundaries matter more at home than in the cloud In the cloud, the provider hid these decisions — block storage "just worked," compute and storage were already separated. At home, you make those calls explicitly, and the wrong ones bite. The two boundaries that matter: - **Compute boundary** — what runs where (which VM, which container, which host). - **Storage boundary** — what data lives on fast local disk vs the bulk pool vs the network share. Get these right and everything downstream is calm. ## The storage boundary: fast state vs bulk data The single most important rule, and the one cloud migrants most often miss: - **App state** (databases, configs, indexes) → fast local SSD/NVMe on the box that runs the app. Latency-sensitive and intolerant of the disk disappearing. - **Bulk data** (media, archives, backups) → the NAS or DAS pool. Throughput-oriented, tolerant of network latency. Putting a database on a NAS share is the classic cloud-migrant mistake — it works until the NAS naps and the database corrupts, exactly the [storage-split lesson](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker) and the [USB-DAS guardrails](https://deploytovps.com/guide/usb-das-mini-pc-storage-guardrails-before-docker). ## The compute boundary: VMs, containers, and one owner per disk On Proxmox, decide deliberately: - **VM vs LXC** per workload (lighter LXC for most services; VMs for isolation or a storage appliance). - **One owner per physical disk** — don't let Proxmox ZFS and a TrueNAS VM both claim the same drives. The full reasoning is in [a sane Proxmox storage plan](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups). ## A quick decision tree - **Database / app state?** → fast SSD on the compute box. Never the NAS. - **Media / large files?** → NAS or DAS bulk pool. - **Needs the GPU (media transcoding, AI)?** → the box with the GPU, passed through [persistently](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing). - **Public-facing and needs uptime?** → consider keeping that one piece on a [small cloud VPS](https://deploytovps.com/guide/budget-homelab-vs-small-vps-mixed-stack), since home power/internet have outages. ## Don't forget the boundary the cloud was handling: access The cloud gave you a public IP. Home likely gives you [CGNAT](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443) and no inbound port. Sort remote access as part of the migration, not after. ![ServerCompass cloud-to-low-cost migration illustration](https://assets.stoicsoft.com/posts/servercompass/oracle-cloud-free-tier-changes-migrate-to-low-cost-vps/featured.png) *Mapping the move in [ServerCompass](https://servercompass.app) — deciding compute and storage boundaries before apps leave the cloud, so the homelab is built once.* ## Migration checklist - [ ] App state on fast SSD; bulk data on NAS/DAS — never databases on the NAS. - [ ] VM vs LXC chosen per workload; one owner per disk. - [ ] GPU workloads on the GPU box, passed through persistently. - [ ] Public/uptime-critical pieces considered for a small cloud VPS. - [ ] Remote access (CGNAT) planned as part of the move. ## Takeaway The cloud was quietly making your compute and storage boundaries for you. Bringing apps home means drawing them yourself — state on fast disk, bulk on the pool, one owner per disk, and a plan for access past CGNAT. Decide the boundaries first and the homelab gets built once, not rebuilt after the first corruption. --- ## Your First Home Server: The OS and Storage Decisions That Decide Everything Source: https://deploytovps.com/blog/guide/first-home-server-os-and-storage-decisions Published: 2026-06-23 Tags: beginner, home-server, docker, storage, jellyfin Beginners can install one app, then stall on the questions that actually matter: which operating model, where the data lives, and what survives growth. A first-week path that won't trap you. Installing your first self-hosted app is easy now — a one-liner, a template, a few clicks. Where beginners actually stall is the layer underneath: *which operating model should the server run, where should the data live, and will this choice survive me adding ten more apps?* Those decisions, made in the first week, quietly determine whether your home server grows gracefully or gets rebuilt in three months. Here's a path that keeps the early choices from boxing you in. ## The decision that comes before any app: the operating model "Where should the service live?" is really "what operating model am I committing to?" The common options, roughly from most-appliance to most-DIY: - **Appliance NAS OS** (TrueNAS, Unraid) — storage-first, with apps bolted on. Great if storage is the point and you want a GUI for it. - **Simple app platform** (CasaOS and similar) — friendly UI over Docker; easy start, less control as you grow. - **Plain Docker on a normal Linux box** — the most flexible and the most transferable skill; a bit more upfront learning. - **Proxmox** — a hypervisor underneath, so you can run *several* of the above as VMs. More power, more to learn. There's no single right answer, but there is a right *fit*: pick for what you'll do most. If it's storage + media, an appliance NAS OS earns its keep. If it's running lots of varied apps and learning, plain Docker (optionally on Proxmox later) travels furthest. ## The storage decision: separate the fast from the bulk on day one Whatever OS you pick, commit to the split that prevents the most pain: - **App data and databases** on fast local SSD. - **Media and bulk files** on the big disks. This single habit avoids the number-one beginner failure — a database on a slow or sleeping disk corrupting — the [app-state vs bulk-storage split](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker) and the [USB-DAS guardrails](https://deploytovps.com/guide/usb-das-mini-pc-storage-guardrails-before-docker). ## The first-week path: one app, one backup, one habit Don't deploy ten things. Deploy *one*, end to end, and build the habits: 1. **One app** that you actually want (Jellyfin, Immich, a dashboard) — and lead with the win, not a Docker course, the [app-first approach](https://deploytovps.com/guide/app-first-install-paths-freshrss-n8n). 2. **One backup**, configured and [restored once](https://deploytovps.com/guide/docker-volume-restore-drill) before real data goes in. 3. **One habit**: HTTPS and a real hostname from the start, not `http://ip:port`. ![ServerCompass selecting the Jellyfin template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *A first app deployed from a template in [ServerCompass](https://servercompass.app) — pick it, deploy it with storage and TLS handled, and learn the concepts from a working server rather than a tutorial.* ## What survives growth (and what doesn't) - **Survives**: the fast/bulk storage split, backups you've tested, plain-Docker skills, a real domain with TLS. - **Doesn't**: app data scattered on whatever disk had room, an appliance UI you've outgrown but can't migrate off, "I'll add backups later." Choosing for growth mostly means not painting yourself into a corner: keep data portable and keep one foot in transferable tooling. ## First-server checklist - [ ] Operating model chosen for your main use (storage/media → appliance; varied apps/learning → Docker/Proxmox). - [ ] Fast SSD for app state, bulk disks for media — decided day one. - [ ] One app deployed end to end with HTTPS. - [ ] One backup configured and restored once. - [ ] Data kept portable so the next decision isn't a trap. ## Takeaway Your first home server isn't decided by which app you install — it's decided by the operating model and the storage layout you pick around it. Choose the OS for what you'll actually do, split fast state from bulk storage on day one, and grow from one well-backed-up app. Those early calls are the ones that survive everything you add later. --- ## Multi-Terabyte Media Libraries Need a Tiered Backup Policy, Not All-or-Nothing Source: https://deploytovps.com/blog/guide/media-library-tiered-backup-policy Published: 2026-06-23 Tags: backup, media-library, immich, storage, homelab You back up app state religiously and freeze on the photo and media library — too big to replicate, too precious to lose. A tiered policy: what gets full backup, what gets cold storage, what gets re-ripped. Most self-hosters back up their app databases and configs diligently, then hit a wall at the media library. A handful of gigabytes of app state is easy to replicate. Ten terabytes of photos and media is a different question: too big to casually mirror, too valuable (in parts) to shrug off. The mistake is treating it as one all-or-nothing decision. The answer is a **tiered policy** that backs up each kind of content according to how replaceable it actually is. ## Sort the library by replaceability, not by size The key question for every chunk of the library is: *if this vanished, could I get it back?* Three tiers fall out: - **Irreplaceable** — personal photos and home video (your Immich library), recordings you can't re-acquire. If it's gone, it's gone forever. - **Re-acquirable with effort** — media you ripped or downloaded and could obtain again, given time and hassle. - **Trivially replaceable** — transcode caches, generated thumbnails, anything the app regenerates. This tiering is the same recoverability-first logic behind [selective Proxmox backups](https://deploytovps.com/guide/proxmox-backup-selective-offsite-planning), applied to media. ## Tier 1: irreplaceable gets the full 3-2-1 Your photo library is the one part that deserves real backup rigor: - **3-2-1**: at least one copy offsite, on different media. The [Immich/Nextcloud data rules](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state) apply — back up the database *and* the originals together so albums and faces survive. - Consider cloud cold storage for the offsite copy; multi-TB of photos is still affordable in archive tiers, and it's the one place skimping isn't worth it. - Verify a restore at least once. ## Tier 2: re-acquirable gets cheap redundancy, not sacred backup For a media library you could rebuild: - **Local redundancy** (a second disk, a parity/RAID setup) protects against the common case — a single drive failing — without paying to replicate terabytes offsite. - An **inventory list** (what you had) is often more valuable than a full byte-for-byte backup; rebuilding from a list beats paying to store what you can re-acquire. - Skip offsite replication unless the re-acquisition cost genuinely exceeds the storage cost. ## Tier 3: don't back up what regenerates Transcode caches, thumbnails, and derived data should be *excluded* from backups entirely — the app rebuilds them. Backing them up wastes space and slows every backup run. Keep them on fast disk (the [app-state vs bulk-storage split](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker)) and let them be ephemeral. ## Storage placement follows the tiers - **App databases + irreplaceable originals' index** → fast SSD, backed up. - **Bulk media** → the big spinning pool, with local redundancy. - **Caches** → fast disk, never backed up. ![ServerCompass self-hosted media server migration illustration](https://assets.stoicsoft.com/posts/servercompass/plex-price-hike-jellyfin-self-hosted-media-server-migration/featured.png) *Planning media storage and backup tiers in [ServerCompass](https://servercompass.app) — separating the irreplaceable photo library from re-acquirable media so each gets the protection it actually warrants.* ## Policy checklist - [ ] Library sorted into irreplaceable / re-acquirable / regenerable. - [ ] Irreplaceable photos on full 3-2-1 with a tested restore. - [ ] Re-acquirable media on local redundancy + an inventory, not offsite replication. - [ ] Caches/thumbnails excluded from backups. - [ ] Storage placement matches the tiers. ## Takeaway A 10TB library isn't one backup decision; it's three. Give your irreplaceable photos real 3-2-1 protection, give re-acquirable media cheap local redundancy and an inventory, and stop backing up caches the app rebuilds anyway. Tier it by replaceability and the "too big to back up" library becomes a solved, affordable problem. --- ## Proxmox Disaster Recovery: Plan the Full-Host Restore Before the Host Dies Source: https://deploytovps.com/blog/guide/proxmox-full-host-disaster-recovery-plan Published: 2026-06-23 Tags: proxmox, pbs, disaster-recovery, backups, reliability Having PBS backups isn't a recovery plan. When a Proxmox host dies, freezes, or must be rebuilt on new hardware, you need the whole path written down — host config, VM state, storage, and order. Most Proxmox users cross "set up backups" off the list and feel safe. Then a host dies — a failed boot disk, a kernel panic loop, hardware that won't POST — and they discover the gap between *having backups* and *knowing the full path back to a running system*. Proxmox Backup Server restores your VMs, but a dead host is more than its VMs: it's the host config, the storage layout, the network, and the order you bring them back. Disaster recovery is that whole path, written down before disaster day. ## Backups restore VMs; they don't restore the host PBS is excellent at backing up and restoring guest VMs and containers. What it doesn't capture by default is the **Proxmox host itself** — the part you'll be rebuilding from scratch on new hardware: - Storage configuration (`/etc/pve/storage.cfg`): which pools, which NFS/iSCSI mounts, which PBS targets. - Network config (bridges, VLANs, bonds). - Cluster config, users, and any custom `/etc` tweaks. - Which VM lives on which storage, and their boot order/dependencies. If all of that lives only in the head of the person who built it, recovery is archaeology under pressure. ## Capture the host config now, while it's healthy The pre-disaster step is to back up the *host's* configuration so a fresh Proxmox install can be made to match: - Periodically copy `/etc/pve/`, `/etc/network/interfaces`, and `/etc/fstab` somewhere off the host. - Write down the storage topology — the same "one owner per disk" map from [a sane Proxmox storage plan](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups). - Keep a short inventory: each VM, its storage, its purpose, and what depends on it. This is config-as-notes at minimum; config-as-code if you can. ## Know the restore order A full-host restore isn't "restore everything at once." Dependencies matter: 1. **Fresh Proxmox install** on the new/repaired hardware. 2. **Reattach storage** and the PBS datastore — restores need the backup target reachable first. 3. **Network** rebuilt to match (bridges/VLANs), so restored VMs land on the right segments. 4. **Restore VMs in dependency order** — a database or storage VM before the apps that need it; a [virtualized router](https://deploytovps.com/guide/router-in-a-vm-homelab-failure-modes) before anything that needs the network it provides. 5. **Verify** each tier before moving up. Writing this order down is most of the value — you won't reason it out correctly while panicking. ## Freeze triage: is the host actually dead? Not every "host down" is a disaster. A frozen console with VMs still serving traffic is usually a [display/proxy issue, not a dead host](https://deploytovps.com/guide/proxmox-vm-console-frozen-recovery) — don't hard-reset a healthy machine. Triage first: - Can you SSH to the host? Do the VMs still answer? - Is it the web UI/console frozen, or the whole node? - Only escalate to a full restore when the host genuinely won't come back. ## The PBS restore must be tested A backup target you've never restored from is a guess. Periodically restore a real VM to a scratch host and confirm it boots — the same [restore-drill discipline](https://deploytovps.com/guide/docker-volume-restore-drill) scaled to whole VMs. And keep the PBS target independent of the host it protects, or a host loss takes the backups with it. ![ServerCompass rollback and recovery playbook](https://assets.stoicsoft.com/posts/servercompass/vps-rollback-playbook-after-bad-deploy.png) *A written recovery playbook in [ServerCompass](https://servercompass.app) — the pre-planned restore order that turns "the host died" from improvisation into a checklist.* ## DR checklist - [ ] Host config (`/etc/pve`, network, storage) backed up off-host. - [ ] VM inventory with storage + dependency notes. - [ ] Restore order written down (install → storage → network → VMs by dependency). - [ ] Freeze triage step before declaring disaster. - [ ] PBS target independent of the host; a full-VM restore rehearsed. ## Takeaway PBS backs up your VMs; it doesn't back up your *recovery*. Capture the host config, write down the restore order, keep a freeze-triage step so you don't nuke a healthy host, and rehearse a real restore. Then a dead Proxmox host is a planned rebuild, not the worst day of your year. --- ## Leaving SaaS for a One-Box VPS? Launch It Behind These Guardrails Source: https://deploytovps.com/blog/guide/saas-avoidant-vps-launch-checklist Published: 2026-06-23 Tags: vps, docker, backups, deployment, self-hosting Choosing root access and a one-box Docker stack to escape SaaS is the easy part. The launch checklist — provider, backups, deploys, monitoring — is what keeps the box from becoming a liability. More builders are reaching the same conclusion: instead of stitching together five SaaS subscriptions, rent one VPS, take root, and run the whole thing as a single Docker stack. It's cheaper, it's yours, and nothing gets deprecated out from under you. The catch is that "I have root and Docker" is the *start* of the work, not the end. The SaaS you left was quietly doing backups, patching, monitoring, and TLS for you. On a one-box VPS, that's all yours now — so launch it behind guardrails instead of discovering each gap during an incident. ## Guardrail 1: pick the provider for the boring reasons The cheapest hourly price is the wrong selection criterion. For a box that will hold real data, weigh: - **Snapshots and backups** the provider offers (and what they cost). - **Network/bandwidth** included before overage. - **Real performance**, not the spec sheet — a [proper benchmark](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs) tells you what the vCPU actually delivers. - **Region and data residency** if that matters to you. Decide this once; migrating providers later because you optimized for $1/month is its own project. ## Guardrail 2: a backup you've restored, before real data lands This is the guardrail people skip and regret. A one-box stack means one failure domain, so backups must leave the box: - Logical database dumps on a schedule, shipped **off the VPS** (object storage or a second host). - App data/config included, consistent with the database. - A restore **rehearsed once** on a scratch target — a [volume restore drill](https://deploytovps.com/guide/docker-volume-restore-drill) turns "I have backups" into "I can recover." Do this before you import anything you'd miss. ## Guardrail 3: deploys that are repeatable, not artisanal The fastest way to lose a one-box setup is to configure it by hand and never write it down. Keep the stack reproducible: - The whole thing in a versioned `docker-compose.yml` with pinned image digests — [test the exact image you deploy](https://deploytovps.com/guide/test-exact-docker-image-before-rollout), don't chase `:latest`. - A documented rebuild path so the box is recreatable, not a unique snowflake — the discipline behind [trustworthy VPS rebuild automation](https://deploytovps.com/guide/vps-rebuild-automation-compose-traefik-mail). - Host prerequisites (sysctls, directory ownership, ports) handled up front — the [hidden prerequisites Compose scripts miss](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight). ## Guardrail 4: monitoring and TLS, because nobody else is watching The SaaS dashboard that told you something broke is gone. Replace it: - An **uptime + certificate check** that runs off the box (so it survives the box being down) and warns before a cert expires — [monitoring beyond a basic uptime ping](https://deploytovps.com/guide/public-service-monitoring-beyond-uptime). - **HTTPS with renewal that reloads** the proxy — the [renew-and-reload trap](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy) is the classic silent failure. - Alerts routed somewhere you'll actually see, not muted. ## Guardrail 5: don't expose what you don't have to One box means a smaller attack surface only if you keep it small. Keep the Docker control plane off the network ([safer management boundaries](https://deploytovps.com/guide/docker-socket-safe-management-boundaries)), publish only the ports you need, and reach admin interfaces over a private path. ## Launch checklist - [ ] Provider chosen for backups/bandwidth/performance, not just price. - [ ] Off-box backups, restore rehearsed once. - [ ] Stack in version control, image digests pinned, rebuild documented. - [ ] Off-box uptime + cert monitoring; HTTPS auto-renew that reloads. - [ ] Management plane private; only necessary ports exposed. ## Manage it from one place A single dashboard over the box — apps, status, deploys, backups — beats SSHing in to check each thing. ![ServerCompass dashboard managing a deployed app stack](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *Operating a one-box stack from the [ServerCompass](https://servercompass.app) dashboard — deploy, restart, and backup controls in one place instead of a folder of SSH commands.* ## Takeaway Trading SaaS for a one-box VPS is a great move, but the subscription was paying for invisible work: backups, monitoring, TLS, and patching. Put those guardrails up *before* the box holds anything you care about, and self-hosting stays the freedom you wanted instead of a pager you didn't. --- ## When You're the Whole Platform Team: A Solo DevOps Operating Model Source: https://deploytovps.com/blog/guide/solo-devops-operating-model-runbook Published: 2026-06-23 Tags: devops, observability, traefik, runbooks, operating-model One person ends up owning deploys, observability, DNS, SSL, and Traefik with no operating model. A durable runbook-driven approach so being the only platform owner doesn't mean being the single point of failure. It's a common and stressful pattern: a small company hands one person — often a junior — the keys to *everything platform*. Deployments, observability, networking, wildcard domains, SSL, the Traefik config nobody else understands. There's no team, no operating model, just a growing pile of responsibilities and the quiet dread that if you're sick, the whole thing is unowned. You can't hire a team by Friday, but you can build an **operating model** that keeps "solo platform owner" from meaning "single point of failure." ## The real problem isn't workload — it's that it's all in your head A solo owner can do the work. The fragility is that the *knowledge* is undocumented and the *processes* are improvised. Fix those two and the load becomes survivable (and you become un-blocking-able). Three moves do most of it. ## Move 1: standardize deploys so they're boring Every deploy done a slightly different way is a separate thing to remember. Collapse them into one path: - One repeatable deploy method, with [the exact image digest tested and promoted](https://deploytovps.com/guide/test-exact-docker-image-before-rollout) rather than rebuilt per environment. - A [rollback that's pre-written and rehearsed](https://deploytovps.com/guide/rollback-plus-alert-context-same-place), so a bad deploy is a 60-second action, not a research project. - Health checks that confirm the *new* release is actually serving, not just that a container started. When deploys are uniform, you stop holding a dozen special cases in working memory. ## Move 2: externalize the knowledge into runbooks The single highest-leverage thing a solo owner can do is write the [symptom-named runbooks](https://deploytovps.com/guide/self-host-runbooks-bus-factor-oncall) that turn tribal knowledge into a checklist: - One page per common failure ("SSL expired," "Traefik 502s," "deploy stuck"), with the literal commands and file paths. - The Traefik/edge config documented — especially the [Cloudflare-and-Traefik seam](https://deploytovps.com/guide/traefik-migration-edge-cloudflare-routing) where wildcard SSL and routing actually break. - Enough that a competent stranger (or future-you at 2am) could recover without you. Runbooks are how a one-person platform survives that person being away. ## Move 3: automate the edge so it doesn't page you The recurring solo-owner fires — expired certs, a renewal that didn't reload — should be automated and monitored, not manually babysat: - Wildcard SSL via DNS-01 with persisted state, renewal that [reloads the proxy](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). - A cert-expiry alert that warns *days* ahead — [monitoring beyond a basic ping](https://deploytovps.com/guide/public-service-monitoring-beyond-uptime). - Alerts scoped and routed so you're paged for real incidents, not noise — the [alerting last-mile](https://deploytovps.com/guide/alerting-last-mile-uptime-kuma-n8n-pager). ## See everything in one place A solo owner can't watch ten dashboards. Consolidate the platform's health into one view so a glance tells you what's wrong. ![ServerCompass one dashboard over hybrid infrastructure](https://assets.stoicsoft.com/posts/servercompass/one-dashboard-hetzner-homelab-hybrid-infra.png) *A single operational view in [ServerCompass](https://servercompass.app) — for a one-person platform team, one pane over deploys, SSL, and health beats juggling separate tools.* ## Operating-model checklist - [ ] One standardized, rehearsed deploy + rollback path. - [ ] Symptom-named runbooks covering SSL, Traefik, deploys. - [ ] Edge (wildcard SSL, renewal) automated and monitored, not manual. - [ ] Alerts scoped/routed to real incidents. - [ ] One consolidated health view. ## Takeaway Being the only platform owner is workable; being the only person who *knows how it works* is the risk. Standardize deploys, write the runbooks, and automate the edge — and you turn a fragile pile of responsibilities into a durable operating model that survives you taking a day off. --- ## Family Self-Hosters Need a Backup and Rollback Preflight Before Every App Upgrade Source: https://deploytovps.com/blog/guide/family-self-host-backup-rollback-before-upgrades Published: 2026-06-22 Tags: backups, rollback, upgrades, family, self-hosted When the household depends on your server, a bad upgrade isn't a hobby setback — it's everyone's photos and files down. A two-minute backup-and-rollback preflight before you hit update. There's a moment that separates a hobby homelab from one a family relies on: the upgrade. When it's just you, a botched update is an evening of tinkering. When your partner's photos, the kids' media, and the household's files all live on your server, "I'll just update real quick" can take everyone's stuff offline right before bed — and that's how trust in self-hosting evaporates. The fix isn't to stop upgrading. It's a short, repeatable preflight before every one. ## Why upgrades are the risky moment Upgrades change two things that can break independently: the **app** (new bugs, changed config) and the **data schema** (migrations that may not reverse — exactly the [Immich database-migration problem](https://deploytovps.com/guide/immich-upgrade-database-restore-drill)). A "quick update" that hits a forward-only migration and then fails leaves you needing to restore, not retry. If you can't restore, you're debugging someone else's irreplaceable data under pressure. ## The two-minute preflight Before clicking update on anything the family uses: 1. **Snapshot the data.** A fresh database dump and a note of where the volume/library is — consistent, with the app stopped if it's database-backed (the [app-owned-state rule](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state)). 2. **Note the current version.** Write down the exact image tag/digest you're upgrading *from*. Rollback means returning to a known artifact, not guessing — the [pin-the-digest discipline](https://deploytovps.com/guide/test-exact-docker-image-before-rollout). 3. **Know the undo.** One sentence: "to roll back, redeploy `app:1.2.3` and restore last night's dump." If you can't write that sentence, don't upgrade yet. 4. **Read the release notes** for breaking changes — 30 seconds that prevents the nastiest surprises. 5. **Pick a low-stakes time.** Not 11pm when everyone's about to use it. ## Make rollback real, not theoretical A rollback plan you've never executed is a wish. Once — on a scratch copy — actually restore a backup and bring the old version back up. That single [restore drill](https://deploytovps.com/guide/docker-volume-restore-drill) is what converts "I think I can roll back" into "I've done it." For a household, that confidence is the whole product. ![ServerCompass rollback playbook after a bad deploy](https://assets.stoicsoft.com/posts/servercompass/vps-rollback-playbook-after-bad-deploy.png) *A rollback playbook in [ServerCompass](https://servercompass.app) — the pre-written undo (restore this backup, redeploy this version) that turns a bad family-server upgrade into a five-minute recovery.* ## Automate the boring parts The reason people skip the preflight is that it's manual. Remove the excuse: - **Scheduled nightly backups** so step 1 is already done most of the time. - **Version pinning** so "what was I running?" is in the config, not your memory. - A second person who can run the [documented rollback](https://deploytovps.com/guide/self-host-runbooks-bus-factor-oncall) if you're not around. ## Preflight checklist - [ ] Fresh, consistent data snapshot taken. - [ ] Current version/digest recorded. - [ ] One-sentence rollback plan written. - [ ] Release notes skimmed for breaking changes. - [ ] Upgrading at a low-stakes time; rollback rehearsed at least once. ## Takeaway When the family depends on your server, every upgrade is a small production change and deserves a production habit. Snapshot the data, record the version, write the one-sentence undo, and rehearse a restore once. Two minutes of preflight is what lets you keep the apps current *and* keep the household's trust. --- ## Jellyfin on Linux: Choosing Between Docker Compose, Portainer, and a Template Source: https://deploytovps.com/blog/guide/jellyfin-deploy-method-compose-portainer-template Published: 2026-06-22 Tags: jellyfin, docker-compose, portainer, deployment, media-server Beginners stall on the first decision — how to actually deploy Jellyfin. Raw Compose, Portainer's UI, or a one-click template? A clear comparison so you pick once and move on. Most people don't stall on running Jellyfin — they stall on the question *before* that: how do I even deploy it? The community throws three answers at a newcomer at once — write a `docker-compose.yml` by hand, use Portainer's web UI, or click a template — and the indecision costs more time than any of the methods would. Here's the honest comparison so you pick once and get to watching things. ## The three methods, plainly **Raw Docker Compose.** You write a `docker-compose.yml` and `.env`, manage volumes and ports yourself, and run `docker compose up`. **Portainer.** A web UI over Docker. You still define the stack (often by pasting Compose), but you get buttons for start/stop/logs and a visual view of what's running. **A managed template.** You pick "Jellyfin" from a catalog and the app, storage, and reverse proxy are provisioned for you. They're not ranked best-to-worst — they're different trade-offs of control vs convenience. ## Compare on what actually matters | | Raw Compose | Portainer | Template | |---|---|---|---| | Learning curve | Steepest | Medium | Lowest | | Control / customization | Total | High | Guided | | TLS + reverse proxy | You wire it | You wire it | Usually included | | Day-2 (logs, restart) | CLI | GUI buttons | GUI | | Best for | Learners who want the internals | Visual managers of many stacks | "Just get it running well" | ## Which should *you* pick? - **You want to learn Docker properly** → raw Compose. The friction is the lesson, and you'll understand every container you run afterward. When you go this route, read the [Jellyfin reverse-proxy and transcoding details](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing) so you wire the hard parts right. - **You run several stacks and want a dashboard** → Portainer. It shines at *managing* many hand-defined stacks, less at making the first one easy. - **You want a solid Jellyfin with TLS and backups, not a Docker course** → a template. This is the [app-first path](https://deploytovps.com/guide/app-first-install-paths-freshrss-n8n): the win first, the concepts later. ## The template path, concretely ![ServerCompass selecting the Jellyfin template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/jellyfin/04-select-jellyfin-template.png) *Choosing Jellyfin from a template catalog in [ServerCompass](https://servercompass.app) — the database, storage, and reverse proxy are provisioned together instead of hand-edited, with the GPU and transcoding setup handled for you.* ## You can change your mind later This isn't a permanent marriage. Because Jellyfin's *data lives in volumes*, you can start with a template and later "graduate" to hand-managed Compose by pointing a new Compose stack at the same volumes — the [data-portability rule](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state) that makes any of these reversible. Pick the one that gets you running now; switching later is a volume move, not a redo. ## Whatever you pick, get these right - HTTPS from the start (the mobile/TV clients prefer a real cert). - Library permissions and paths the container can actually read — the [scan-failure trap](https://deploytovps.com/guide/jellyfin-docker-troubleshooting-scans-to-logs). - A backup of the Jellyfin database, not just the media. ## Takeaway The slowest part of deploying Jellyfin is choosing how. Raw Compose to learn, Portainer to manage many stacks visually, a template to just get a good instance fast — all three end at the same working server. Pick by which trade-off fits *you* today, knowing your data's portability lets you switch whenever you want. --- ## A Safer Reverse-Proxy Decision Tree for Self-Hosted Media Apps Source: https://deploytovps.com/blog/guide/media-app-reverse-proxy-decision-tree Published: 2026-06-22 Tags: reverse-proxy, jellyfin, immich, remote-access, media-server Should Jellyfin and Immich be public behind a reverse proxy, private over a VPN, or both? A decision tree that weighs privacy, family access, and streaming bandwidth instead of guessing. "Should I put Jellyfin behind a reverse proxy and make it public, or keep it private over a VPN?" is the most-asked self-hosted media question, and the answer is genuinely "it depends" — but on a small number of things you can decide in five minutes. The mistake is answering it by copying someone else's setup whose priorities weren't yours. Here's a decision tree that weighs the factors that actually matter: who needs access, how private the content is, and how much bandwidth the streams need. ## First, the three factors 1. **Who needs in?** Just you and devices you control? Or family/friends who won't install a VPN client? 2. **How sensitive is the content?** A movie library is different from a private photo collection where every frame is personal. 3. **How heavy is the traffic?** High-bitrate video streaming is bandwidth-hungry; a photo timeline is light by comparison. ## The decision tree **Only you, on devices you control → VPN-only.** No public exposure at all. Run WireGuard/Tailscale, require the VPN to reach the app, and you've got the smallest possible attack surface. Perfect for a private Immich. **Family/friends who won't run a VPN → public, behind a reverse proxy, with auth.** They need a clean `https://` URL that just works. Put the app behind a reverse proxy with TLS *and* an authentication layer — never rely on the app's login alone for something internet-facing. Mind the app-specific settings (upload size, WebSockets, timeouts) in [Immich behind a reverse proxy](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases); they apply to any media app. **Private content + a few trusted people → mesh share, not public.** Tailscale (or Headscale) lets you share access to specific people without opening the app to the internet — the middle ground that keeps a private photo library off the public web while still sharing it. The [fleet-access patterns](https://deploytovps.com/guide/remote-self-host-fleet-access-beyond-simple-tunnels) cover this. **Behind CGNAT (no inbound port) → VPS relay or tunnel.** Your ISP removed the public port, so accept connections elsewhere. The full menu — VPS reverse-proxy relay, Cloudflare Tunnel (watch plaintext for media), Tailscale Funnel — is in [reaching apps behind CGNAT](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443). ## The privacy rule that overrides convenience For *private* media, don't let an external edge that terminates TLS see your content in plaintext. A movie catalog through a third-party proxy is one risk calculus; your family photos are another. Keep TLS terminating on hardware you control (your VPS or your box) for anything sensitive — even if a hosted tunnel would be one click easier. ## The bandwidth reality Public Jellyfin streaming runs real video through whatever's in the path. A VPS relay pays for that egress; a direct (CGNAT-free) connection doesn't. Size for [direct play over transcoding](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing) to cut bandwidth, and don't route heavy streams through a tunnel that throttles or bills them. ![ServerCompass self-hosted media server migration illustration](https://assets.stoicsoft.com/posts/servercompass/plex-price-hike-jellyfin-self-hosted-media-server-migration/featured.png) *Planning media access in [ServerCompass](https://servercompass.app) — matching the exposure model to who needs in and how private the library is, rather than copying a stranger's reverse-proxy config.* ## Quick chooser - [ ] Just me → VPN-only. - [ ] Non-technical family → public reverse proxy **+ auth + TLS**. - [ ] Private content, few people → mesh share, not public. - [ ] Behind CGNAT → VPS relay / tunnel (TLS you control for private media). - [ ] Heavy streaming → prefer direct play; mind egress. ## Takeaway There's no universal right answer for exposing media apps — but there's a right answer for *your* three factors. Decide who needs access, how private the content is, and how heavy the streams are, then follow the tree. VPN-only for you, authenticated reverse proxy for family, mesh shares for private content, a relay for CGNAT — chosen on purpose, not copied. --- ## Self-Hosted Operators Want Monitoring Beyond a Basic Uptime Check Source: https://deploytovps.com/blog/guide/public-service-monitoring-beyond-uptime Published: 2026-06-22 Tags: monitoring, uptime, tls, synthetic-checks, self-hosted A green 'it responds' check misses expired certs, broken logins, silent data staleness, and slow-but-up services. What real public-service monitoring looks like beyond a ping. A basic uptime check answers one question: did the server return a response? That's necessary and nowhere near sufficient. Plenty of outages are *up* the whole time — the TLS cert expired so browsers refuse the site, login is broken behind a 200-OK homepage, the data is hours stale, or the page loads but takes nine seconds. Self-hosted operators keep asking for "monitoring beyond uptime" because the green checkmark keeps lying to them. Here's what that actually means. ## The failures a ping misses - **Expired certificate.** The server responds fine over HTTP; browsers and mobile apps reject it over HTTPS. Your uptime check (often ignoring cert validity) stays green while users see a scary warning. - **Broken authentication.** The homepage returns 200; login throws a 500. "Up" by the check, useless to users. - **Stale data.** The app responds instantly with content from a sync that died yesterday. Nothing is "down," everything is wrong. - **Slow but up.** A 200 after 9 seconds is a failure to a human and a success to a naive check. ## Layer the checks Add checks that match how the service actually fails: - **Certificate expiry** — alert *days before* a cert expires, not when it already broke. The cheapest high-value check there is. - **Content/keyword checks** — assert the response *contains the expected text* (a logged-in marker, a "last updated" timestamp), not just that it returned. This catches broken auth and stale data. - **Latency thresholds** — alert when response time crosses a budget, so "slow but up" becomes visible. - **Functional/synthetic checks** — for critical flows, script the real action (log in, load a record) rather than hitting the homepage. ## Keep it low-noise or you'll mute it More checks means more chances to cry wolf. The discipline that keeps richer monitoring usable: - Alert on **sustained** failure, not a single blip (require N consecutive fails). - Route by **severity** — a cert expiring in 7 days is a warning; the site down now is a page. - Carry context to the fix. The [low-noise uptime-check](https://deployhandbook.com/best/low-noise-uptime-checks-homelab) approach and the [alerting last-mile fixes](https://deploytovps.com/guide/alerting-last-mile-uptime-kuma-n8n-pager) apply directly. ![ServerCompass routing checks to Discord and Telegram by severity](https://assets.stoicsoft.com/posts/servercompass/alert-routing-discord-telegram-severity-templates.png) *Severity-routed monitoring in [ServerCompass](https://servercompass.app) — a cert-expiry warning and a hard-down page land in different places, so deeper checks don't drown you in noise.* ## Don't forget the cert-renewal loop itself The most common "expired cert" cause isn't forgetting to monitor — it's a renewal that runs but doesn't reload the proxy, the exact trap in [Let's Encrypt renew-and-reload behind a Docker proxy](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). Monitor the expiry *and* fix the renewal. ## Checklist - [ ] Certificate-expiry check that warns days ahead. - [ ] Content/keyword assertions, not just a 200. - [ ] Latency budget alerting for "slow but up." - [ ] Synthetic checks for critical flows (login, key page). - [ ] Sustained-failure + severity routing to stay low-noise. ## Takeaway "Is it up?" is the wrong question, because the answer is often yes while the service is broken. Check the cert before it expires, assert the page says what it should, watch latency, and script the flows that matter — then route it all low-noise so the richer signal stays trustworthy. Real monitoring tells you the service *works*, not merely that it *answered*. --- ## Small Next.js Apps Are Getting Priced Out of Vercel Plus Its Add-Ons Source: https://deploytovps.com/blog/guide/small-nextjs-apps-vercel-to-vps-cost Published: 2026-06-22 Tags: nextjs, vercel, vps, cost, self-hosted A hobby Next.js app on Vercel is free until you add a database, cron, and bandwidth — then it's $40+/month. When moving the small app to a VPS makes sense, and what you take on. Vercel is a wonderful place to start a Next.js app and an increasingly expensive place to keep a *small* one. The free tier is genuinely generous — until the app grows the things real apps need: a database, scheduled jobs, more bandwidth, maybe a bit more compute. Each is its own line item or a paid plan, and the bill for a hobby project quietly crosses into "wait, I'm paying $40 a month for this?" That's the moment to ask whether a single small VPS does the same job for the price of a coffee. ## Where the Vercel bill actually comes from The platform itself is rarely the whole cost; the *add-ons* are: - **Database** — a managed Postgres add-on with its own monthly price and usage metering. - **Cron / background jobs** — scheduled functions, often gated behind a paid tier. - **Bandwidth & function invocations** — fine at hobby scale, metered as you grow, with overages. - **Team/pro features** you bumped into for one capability. Stacked, a "free" project becomes a multi-service subscription. (This is the same dynamic Oracle free-tier refugees hit — convenience priced per add-on.) ## What a VPS gives you for one flat price A single small VPS runs the *whole* shape on one bill: - **The Next.js app** itself (built `standalone` and run in Docker, or with a Node process manager). - **A Postgres** right next to it — no per-add-on metering (just mind [database sprawl](https://deploytovps.com/guide/homelab-database-sprawl-consolidation) if you add more apps). - **Cron** via the OS, not a paid scheduler. - **Bandwidth** within the VPS's generous included transfer, not metered per request. One predictable monthly number, often less than the database add-on alone cost. ## What you take on (be honest) Self-hosting isn't free of cost — it moves cost to your time: - **Builds and deploys** are now yours: a CI step or a [pinned-image promotion flow](https://deploytovps.com/guide/test-exact-docker-image-before-rollout) instead of git-push-to-deploy. - **TLS, reverse proxy, and the edge** are your job — the things [Vercel handled for you in a PaaS-to-VPS migration](https://deploytovps.com/guide/paas-to-vps-migration-checklist-what-vercel-railway-handled-for-you). - **Uptime and patching** are on you. For a small app that's minutes a month; it's not zero. ## When to move and when to stay - **Move** when the add-ons (database + cron + bandwidth) dominate the bill, the app is small enough to fit a $5–10 VPS, and you're comfortable owning deploys. - **Stay** when git-push-deploy and zero-ops are worth the premium, traffic is genuinely spiky (serverless scale-to-zero earns its keep), or it's a throwaway you don't want to operate. A middle path: keep the static/edge bits where they're cheap and move the *stateful* parts (database, cron) to the VPS — the [hybrid logic](https://deploytovps.com/guide/budget-homelab-vs-small-vps-mixed-stack) of putting each workload where it's cheapest. ## Migration sanity checks - [ ] Build Next.js in `standalone` mode for a lean container. - [ ] Move the database with a real dump, not a file copy. - [ ] Reproduce cron jobs as OS cron / a small scheduler. - [ ] Wire TLS + reverse proxy (and renewal that reloads). - [ ] [Benchmark the VPS](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs) so it actually handles your traffic. ## Takeaway Vercel earns its price for some apps and outgrows its value for small ones the moment you bolt on a database, cron, and bandwidth. If your app fits a cheap VPS and you'll own the deploys, one flat-rate box replaces a stack of metered add-ons — and the only real cost is the operations you take back. Decide by where your bill comes from, not by platform loyalty. --- ## Traefik Migrations Keep Failing at the Edge Where Cloudflare and App Routing Meet Source: https://deploytovps.com/blog/guide/traefik-migration-edge-cloudflare-routing Published: 2026-06-22 Tags: traefik, cloudflare, reverse-proxy, tls, migration Moving to Traefik breaks in a predictable place: the seam between Cloudflare's edge and Traefik's routing — redirect loops, wrong client IPs, and certs that won't issue. How to fix the seam. Migrating to Traefik usually goes smoothly right up to one place: the **edge**, where Cloudflare sits in front and Traefik does the routing behind it. That seam is where the predictable failures cluster — infinite HTTPS redirect loops, every visitor showing up as a Cloudflare IP, and ACME certificates that refuse to issue. None of them mean Traefik is wrong; they mean the two layers disagree about who terminates TLS and where the real client is. Fix the seam and the migration completes. ## Failure 1: the infinite redirect loop You enable "redirect HTTP to HTTPS" in both Cloudflare and Traefik, set Cloudflare's SSL mode to **Flexible**, and the browser bounces forever. Flexible means Cloudflare talks HTTP to your origin, but Traefik then redirects that to HTTPS, which Cloudflare downgrades again — a loop. The fix: - Set Cloudflare SSL to **Full (strict)** so it speaks HTTPS to Traefik end to end. - Let Traefik hold a valid cert (real, or a Cloudflare Origin cert) so "strict" validates. - Don't double-redirect: pick one layer to enforce HTTPS. This is the single most common Traefik-behind-Cloudflare bug. ## Failure 2: every client looks like Cloudflare Behind the orange cloud, your app sees Cloudflare's IPs as the client — breaking rate limits, geo logic, and logs. Traefik must trust Cloudflare's forwarded headers: - Configure Traefik's `forwardedHeaders.trustedIPs` (or the proxy protocol) with Cloudflare's IP ranges so it reads the real `CF-Connecting-IP`/`X-Forwarded-For`. - Then your app sees actual visitors, not the CDN — the same `X-Forwarded-*` discipline that [Immich behind a reverse proxy](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases) needs to build correct URLs. ## Failure 3: ACME certs won't issue Traefik's default HTTP-01 challenge needs Let's Encrypt to reach your origin on port 80 — but Cloudflare's proxy intercepts it, and the challenge fails. Two fixes: - Use the **DNS-01 challenge** via Cloudflare's API, so issuance doesn't depend on inbound HTTP at all (also the only way to get wildcard certs). - Persist `acme.json` so a migration/rebuild doesn't reissue everything and hit [rate limits](https://deploytovps.com/guide/vps-rebuild-automation-compose-traefik-mail). Make sure renewal actually reloads — the [renew-and-reload trap](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy) bites here too. ## Failure 4: routing rules that worked in nginx don't map cleanly Traefik's label/router model is different from an nginx `server` block, and migrations often mis-translate path priorities and host rules. Verify each route explicitly after the move rather than assuming parity — the general [edge TLS vs end-to-end](https://deploytovps.com/guide/reverse-proxy-edge-tls-vs-end-to-end) reasoning helps decide where TLS should actually terminate. ## Migration checklist - [ ] Cloudflare SSL = Full (strict); HTTPS enforced in one layer only. - [ ] Traefik holds a valid/origin cert. - [ ] `forwardedHeaders.trustedIPs` set to Cloudflare ranges (real client IP restored). - [ ] DNS-01 ACME via Cloudflare API; `acme.json` persisted; renewal reloads. - [ ] Every route re-verified, not assumed from the old config. ## Takeaway Traefik migrations don't fail in Traefik — they fail at the Cloudflare seam. Set SSL to Full (strict) and stop double-redirecting, teach Traefik to trust Cloudflare's forwarded IPs, switch ACME to the DNS challenge, and re-verify your routes. Align the two layers on who terminates TLS and where the client really is, and the edge stops fighting you. --- ## Container-First Linux Troubleshooting: A Logs, Process, and Network Path for VPS Apps Source: https://deploytovps.com/blog/guide/container-first-linux-troubleshooting-logs-process Published: 2026-06-22 Tags: docker, linux, troubleshooting, logs, networking Troubleshooting a containerized app on a VPS isn't classic Linux debugging — the process, logs, and network live one layer in. A repeatable path from 'it's broken' to the actual cause. When a containerized app on a VPS breaks, the old Linux reflexes only half-apply. The process you're looking for isn't in the host's normal process list where you expect it; the logs aren't in `/var/log`; the port that's "open" is open in a network namespace one layer in. Container-first troubleshooting is the same discipline — logs, process, network — pointed at the right layer. Here's a path that gets you from "it's broken" to the cause without flailing. ## Start with logs — but the container's Ninety percent of the time the answer is in the logs, and for a container that means `docker logs`, not the host's syslog: ```bash docker logs --tail 100 -f ``` Read the *last clean startup* and the first error after it. A container in a restart loop prints the same fatal line every few seconds — that line is usually the whole answer (a missing env var, an unwritable volume, a failed dependency). This is the same "read the log first" habit that solves most [Jellyfin issues](https://deploytovps.com/guide/jellyfin-docker-troubleshooting-scans-to-logs); it generalizes to every container. ## Then the process and its exit If logs are thin, look at lifecycle: - `docker ps -a` — is it running, restarting, or exited? An **exit code** is a clue: 137 is OOM-killed (raise the [memory limit](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts) or fix the leak), 1 is a generic app error, 0 with a restart means it thinks it finished. - `docker exec -it sh` — get *inside* the namespace to check what the app sees: its config, its files, whether the path it wants exists. The bug is often a [host-vs-container path or permission mismatch](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight). ## Then the network — at the right layer "The port's open but I can't reach it" is a layered question: - Is the app **listening inside** the container? `exec` in and check it's bound to `0.0.0.0`, not `127.0.0.1` (a container bound to localhost is unreachable from outside it). - Is the port **published** to the host (`docker ps` port mapping)? - Is the **reverse proxy** pointed at the right container/port? A stale upstream is the classic [intermittent-502 cause](https://deploytovps.com/guide/intermittent-502-stale-proxy-upstream). - Is a **host firewall** blocking the published port? Walk it from inside out and you'll find which layer drops the connection. ## Health checks turn mysteries into signals Half of "why is it broken" is "I didn't know it was broken." Good [Docker health-check defaults](https://deploytovps.com/guide/docker-health-checks-sane-defaults-for-self-hosted-services) make the container report its own state, so `docker ps` shows `unhealthy` instead of you discovering it from a user. ## The repeatable path 1. `docker logs` — read the last startup and first error. 2. `docker ps -a` — running/restarting/exited + exit code. 3. `docker exec` — inside the namespace: config, files, what the app binds to. 4. Network outward: container listen → published port → proxy → firewall. 5. Add a health check so the next failure announces itself. ## Takeaway Containerized troubleshooting isn't harder than classic Linux debugging — it's the same logs-process-network path aimed one layer in. Read the container's logs first, check its exit code and what it sees from inside the namespace, then walk the network from the app's bind outward. Do it in that order and "the container's broken" becomes a specific, fixable line every time. --- ## Immich Upgrade Anxiety Is Really a Database-Restore Problem Source: https://deploytovps.com/blog/guide/immich-upgrade-database-restore-drill Published: 2026-06-22 Tags: immich, upgrades, backups, postgresql, restore-drill Every Immich release note warns about breaking changes, and a generic file backup won't save you. The thing that makes Immich upgrades calm is a tested database restore, not crossed fingers. Immich moves fast, and its release notes don't sugarcoat it: major versions run database migrations that are **not reversible**. That's why upgrade day makes people nervous — and why the usual reassurance ("I have a backup of my photos") is the wrong reassurance. Your originals are rarely the thing at risk. The thing at risk is the **database** that knows about your albums, faces, and metadata, and the only thing that makes an Immich upgrade calm is a database restore you've actually performed. ## Why a file backup isn't enough Immich is a database-plus-files app (the same shape as the [Immich/Nextcloud migration rules](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state)). Copy the upload folder and you've saved the pixels, not the meaning — albums, people, search, and timeline all live in Postgres. An upgrade that migrates the schema forward and then fails leaves you needing to restore *that database to its pre-upgrade state*. A folder copy can't do it. ## The backup that actually protects an upgrade Before any major upgrade: 1. **Stop Immich** so the database is quiescent. 2. **Dump the Postgres database** with `pg_dump` (Immich documents the exact command for its bundled Postgres — use it; a plain copy of the data dir is not a clean dump). 3. **Note the current version.** You can only restore *into the same version* you dumped from — never an older binary against a newer schema. 4. Keep the dump **off the box**, alongside the library. ## Rehearse the restore once — before you need it The dump is half the insurance; restoring it is the other half. Do it once on a scratch target: - Stand up the *same* Immich version, restore the dump, point it at a copy of the library, and confirm albums and faces come back. - That single rehearsal converts "I think this works" into "I've watched it work" — the whole point of a [restore drill](https://deploytovps.com/guide/docker-volume-restore-drill). ## Upgrade sequence that removes the fear 1. Read the release notes for breaking changes and required steps. 2. Stop Immich; dump the database; record the version; copy off-box. 3. Upgrade to the new version (only ever forward). 4. Let migrations run; verify the timeline, albums, and a face search. 5. If it's wrong: restore the dump into the *old* version, and retry the upgrade after addressing the noted issue. ## Make backups routine, not heroic The reason people skip the dump is that it's manual. Put it on a schedule (a nightly `pg_dump` shipped off-box) so every day is a near-upgrade-ready state, and an actual upgrade just adds one fresh dump on top. A managed deploy that includes scheduled database backups removes the main excuse — the boring discipline behind [an easy, durable Immich install](https://deploytovps.com/guide/immich-easy-install-path-for-nontechnical-users). ## Checklist - [ ] Database dumped with the documented `pg_dump`, not a file copy. - [ ] Current version recorded; restore only into the same version. - [ ] Dump stored off-box with the library. - [ ] A restore rehearsed once on a scratch target. - [ ] Routine scheduled DB backups so upgrades start from a known-good state. ## Takeaway Immich upgrade anxiety is rational and misdirected. The risk isn't your photos; it's the database that gives them meaning, and Immich's migrations don't roll back. Dump the database before you upgrade, only restore into the matching version, and rehearse it once. Do that and "new major version" stops being a held breath. --- ## Jellyfin Docker Troubleshooting: From Library Scans That Miss Files to Reading the Logs Source: https://deploytovps.com/blog/guide/jellyfin-docker-troubleshooting-scans-to-logs Published: 2026-06-22 Tags: jellyfin, docker, troubleshooting, transcoding, media-server Most Jellyfin problems are one of three things — a library scan that won't see files, hardware transcoding that isn't, or playback that fails. A path from symptom to the right log line. Jellyfin in Docker generates a steady stream of "it's not working" that turns out to be one of three problems wearing different masks: the library scan doesn't see your files, hardware transcoding isn't actually engaging, or playback fails for a specific client. Each has a tell, and each has a log that confirms it. Here's the path from symptom to the right line. ## Problem 1: the library scan misses files You point Jellyfin at a folder full of media and the scan finds nothing, or half. In a container it's almost always one of: - **Permissions** — the path is bind-mounted but owned by a user the Jellyfin container can't read. Match the container's `PUID`/`PGID` to the files' owner. This is the same [host-directory-ownership trap](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight) that bites every bind-mounted app. - **Path mismatch** — the path *inside* the container differs from the host path you think you set. Jellyfin scans the container path; verify what it actually sees, not what's on the host. - **Naming** — Jellyfin needs recognizable `Show/Season/Episode` or `Movie (Year)` structure. Files it can't parse get skipped silently. Confirm in the **scan log**: it lists what it found and what it skipped. Read that before guessing. ## Problem 2: hardware transcoding that isn't The CPU melts during playback and you *thought* you enabled hardware transcoding. Two checks: - The render device is actually **passed into the container** (`/dev/dri` present) and the Jellyfin user is in the right group — the persistent [GPU passthrough setup](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing) for Proxmox media servers. - The **playback log** shows the hardware encoder being used, not `libx264` (software). If it says software, the device or the codec settings aren't right. Best of all: arrange [direct play](https://deploytovps.com/guide/media-app-reverse-proxy-decision-tree) so most sessions don't transcode at all. ## Problem 3: playback fails on one client It plays on the TV, fails on the phone (or vice versa). That's almost always a codec/container the client can't handle, forcing a transcode that then fails or stutters. The playback log names the source and target formats — read it and you'll see exactly where the conversion broke. ## Reading Jellyfin's logs (the actual skill) The recurring lesson: stop guessing, read the log. Jellyfin's logs are detailed and most "mystery" issues are stated plainly in them. - **Scan issues** → the library scan log (what was found/skipped/errored). - **Playback/transcode issues** → the FFmpeg/playback log (source format, target, encoder). - **Container won't start / crashes** → `docker logs` first, then the app log. This logs-process-IO troubleshooting flow generalizes well beyond Jellyfin — see [container-first Linux troubleshooting](https://deploytovps.com/guide/container-first-linux-troubleshooting-logs-process). ## Troubleshooting checklist - [ ] Scan finds nothing → PUID/PGID and the *container* path; check the scan log. - [ ] CPU pinned on playback → `/dev/dri` present; playback log shows hw encoder. - [ ] Fails on one client → codec/container mismatch in the playback log. - [ ] Always: read the relevant log before changing settings. ## Takeaway Jellyfin troubleshooting is mostly three problems and one habit. Fix scans with permissions, container paths, and naming; fix transcoding by verifying the device is passed in and the log shows the hardware encoder; fix client playback by reading the format mismatch in the log. The habit — read the log first — solves more than any setting you'll toggle blindly. --- ## Observability Migrations That Don't Hand You a Surprise Query Bill Source: https://deploytovps.com/blog/guide/observability-migration-without-query-bills Published: 2026-06-22 Tags: observability, prometheus, grafana, loki, cost Small teams leaving a hosted observability vendor often trade a data bill for a query bill. How to migrate to Grafana/Prometheus/Loki without metering yourself into the same trap. Small teams leave a hosted observability vendor to escape an unpredictable bill — and a surprising number land somewhere just as expensive, because they self-hosted the *storage* but not the *discipline*. The hosted bill was metered on ingest and queries; a careless self-hosted stack just moves the cost to disk, RAM, and a Grafana that times out on every dashboard. Migrating well means planning retention, cardinality, and sampling before you flip, not after the volume explodes. ## The cost just changes shape Hosted observability charges for data in and questions asked. Self-hosted doesn't bill you — but unbounded metrics cardinality eats RAM, unbounded logs eat disk, and unbounded retention eats both forever. The "surprise query bill" becomes a "Prometheus OOM'd again" and a 500 GB Loki volume. Same problem, different invoice. Plan the limits up front. ## Lever 1: retention by signal, not one global number Not every signal deserves the same lifespan: - **High-resolution metrics** — keep days to weeks; downsample older data rather than storing raw forever. - **Logs** — the biggest volume; keep recent logs hot and short, archive or drop the rest. Decide how far back an incident could hide and keep exactly that. - **Long-term trends** — keep cheap downsampled rollups, not raw points. ## Lever 2: control metric cardinality Cardinality — the number of unique label combinations — is what actually kills self-hosted Prometheus. A label like `user_id` or `request_id` multiplies series into the millions and the RAM follows. Keep labels bounded (status, route-class, instance), never unbounded identifiers. This one discipline prevents most self-hosted metrics blowups. ## Lever 3: sample and scope logs You rarely need every debug line forever. Sample high-volume logs, scope levels (info+ in prod, not debug), and route only what you'll actually query into the indexed store. Loki's label discipline mirrors Prometheus's: index a few labels, not the log contents. ## Lever 4: size the box for the real volume Once retention and cardinality are bounded, size the host honestly — and [benchmark it for real](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs) rather than trusting specs, because observability is RAM- and IO-hungry. Set memory limits so the stack can't take the whole box, the same [resource discipline](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts) every heavy service needs. ## Don't rebuild what alerting already solved Observability isn't only dashboards — it's the path to a human. Reuse the [low-noise uptime-check](https://deployhandbook.com/best/low-noise-uptime-checks-homelab) approach and the [alerting last-mile fixes](https://deploytovps.com/guide/alerting-last-mile-uptime-kuma-n8n-pager) rather than wiring noisy alerts off raw metrics. ## See it in one place The payoff of self-hosting is one consolidated view you own — metrics, logs, and health together instead of three vendor tabs. ![ServerCompass single dashboard over hybrid infrastructure](https://assets.stoicsoft.com/posts/servercompass/one-dashboard-hetzner-homelab-hybrid-infra.png) *A single pane over the whole fleet in [ServerCompass](https://servercompass.app) — the consolidated health view that makes self-hosted observability worth the migration.* ## Migration checklist - [ ] Retention set per signal; old metrics downsampled, old logs aged out. - [ ] Metric labels bounded; no unbounded IDs as labels. - [ ] Logs sampled and level-scoped; only queryable data indexed. - [ ] Host sized to real volume with memory limits. - [ ] Alerting reused, not rebuilt noisy. ## Takeaway Leaving a hosted observability vendor saves money only if you carry the discipline across with the data. Bound retention, cardinality, and log volume before you migrate, size the box for the real load, and you get an observability stack you own without the surprise bill following you home. --- ## Planning a First Proxmox Server That Bundles Media, Photos, Backups, and Logging Source: https://deploytovps.com/blog/guide/proxmox-first-server-planning-bundle Published: 2026-06-22 Tags: proxmox, planning, homelab, beginners, architecture First-time Proxmox builds now try to do everything at once — Jellyfin, Immich, backups, and monitoring on one box. A planning order that keeps that ambition from collapsing under itself. First Proxmox builds have gotten ambitious. Where people once started with a single VM, the modern first-server plan bundles everything at once: Jellyfin for media, Immich for photos, a backup server, and a logging/monitoring stack — all on one box, day one. That's a fine destination and a terrible starting point if you build it in the wrong order. The trick is a planning sequence where each layer is stable before the next lands on top of it. ## Plan in layers, bottom-up The failure mode is building top-down — installing apps first, then discovering the storage and isolation underneath them were wrong. Invert it: 1. **Storage first.** Decide disk ownership and layout before a single VM exists. 2. **Isolation second.** VM vs LXC per workload. 3. **Resources third.** Who gets how much CPU/RAM, so apps can't starve each other. 4. **Apps fourth.** Now deploy onto a foundation that won't move. 5. **Backups + monitoring last** — wrapping the whole thing. ## Layer 1: storage with one owner per disk This is where first servers most often go wrong. Decide now: does Proxmox own the disks (ZFS on Proxmox) or does a storage VM, with the controller passed through? Don't half-virtualize. Separate fast app-state storage from bulk media storage. The full reasoning is in [a sane Proxmox storage plan](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups) — read it *before* you create the pool, because changing it later means moving everything. ## Layer 2: VM vs LXC, deliberately - **LXC containers** for lightweight services and anything sharing the host GPU (Jellyfin transcoding) — lighter, faster, easier device passthrough. - **VMs** for things needing strong isolation or a different kernel, or a storage appliance. Mixing is normal; choosing at random is not. Media with hardware transcoding in particular has specific needs — see [media servers on Proxmox](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing). ## Layer 3: resource budgets so nothing starves On a do-everything box, the danger is one workload eating the rest. Budget CPU/RAM per VM/CT up front: Immich's ML and Jellyfin's transcoding both spike, and a logging stack quietly grows. Cap them so a spike is contained, the same [resource-limit discipline](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts) every shared host needs. ## Layer 4: deploy apps onto the stable base Only now do the apps go on — and with the foundation set, deploying from templates keeps each one consistent instead of a pile of hand-wired Compose files. ![ServerCompass app management dashboard with a deployed stack](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *Deploying and managing the app layer in [ServerCompass](https://servercompass.app) once the Proxmox storage and isolation underneath are settled — apps land on a foundation that won't shift.* ## Layer 5: backups and monitoring around everything Wrap the box with [selective offsite backups](https://deploytovps.com/guide/proxmox-backup-selective-offsite-planning) (tier what's irreplaceable) and a single health view. Don't bolt these on after a scare — they're part of the plan, not a reaction to losing data. ## Planning checklist - [ ] Storage ownership and fast/bulk split decided before any VM. - [ ] VM vs LXC chosen per workload, not at random. - [ ] CPU/RAM budgeted so spikes stay contained. - [ ] Apps deployed onto the settled base (templates for consistency). - [ ] Backups (tiered/offsite) and monitoring designed in, not after. ## Takeaway An all-in-one first Proxmox server is a great goal reached in the wrong order by most beginners. Build bottom-up — storage, isolation, resources, apps, then backups and monitoring — and each layer holds the next. Ambition isn't the problem; sequence is. --- ## Proxmox Recovery When the VM Console Freezes but SSH and Apps Still Work Source: https://deploytovps.com/blog/guide/proxmox-vm-console-frozen-recovery Published: 2026-06-22 Tags: proxmox, troubleshooting, console, recovery, vm The noVNC console is black and unresponsive, yet the VM's apps answer and SSH works fine. Counterintuitively, that's good news — here's how to recover without a risky hard reset. It looks like a disaster: you open the Proxmox console for a VM and get a black, frozen noVNC screen that won't take a keystroke. But then you notice the VM's website still loads and `ssh` into it works perfectly. Counterintuitively, that combination is *good* news — a frozen console while the guest is clearly alive almost never means the VM is dead. It means the *display path* is broken, not the machine. Recognizing that saves you from the worst move: a panicked hard reset of a perfectly healthy VM. ## What the symptom actually means The console (noVNC/SPICE) is a separate channel from the guest OS. If apps respond and SSH works, the kernel is scheduling, the network stack is up, and disk IO is happening. A dead console with a live guest points at: - The **VNC/SPICE display** in the guest (a crashed X server or display manager) — the OS is fine, the GUI isn't. - The **noVNC websocket/proxy** on the Proxmox side (a `pveproxy` hiccup or a browser/websocket issue). - A **stuck console session** holding the display. None of those require touching the running VM. ## Recover the console without touching the VM Work from least invasive to most: 1. **Reload the browser tab / try another browser.** noVNC is a websocket app; a stale tab is the most common cause. 2. **Restart the Proxmox web proxy** on the host: `systemctl restart pveproxy` (and `pvedaemon` if needed). This restarts the console plumbing, not the VM. 3. **Use SSH for the actual work.** Since SSH works, you don't *need* the console — do whatever you logged in to do over SSH. 4. **Restart only the guest's display** if it's a Linux GUI VM (`systemctl restart display-manager`) — fixes the GUI without rebooting. ## When (and how) to reset — carefully Only consider a reset if the guest is *truly* unresponsive (SSH dead, apps down) — which is not this scenario. If you must: - Prefer a **graceful shutdown** (`qm shutdown `) so the guest flushes disks. A hard `qm stop`/`qm reset` is a power-pull and risks the same corruption as yanking the cord — especially for database VMs. - Make sure you have a [tested backup and a rollback plan](https://deploytovps.com/guide/proxmox-migration-confidence-checks) before any forced action. The instinct to hard-reset a VM whose console is black — but whose apps are serving traffic — is how a display glitch becomes a real outage. ## Prevent the panic next time - Keep **SSH as your primary access**, the console as a fallback; then a dead console is a non-event. - Treat a black console as a [routine symptom to follow a runbook for](https://deploytovps.com/guide/self-host-runbooks-bus-factor-oncall), not an emergency. - For headless server VMs, you don't need a graphical console at all — fewer moving parts to freeze. ## Recovery checklist - [ ] Confirm the guest is alive (SSH + app responds) before doing anything. - [ ] Reload the console tab / try another browser first. - [ ] Restart `pveproxy` on the host if the console plumbing is stuck. - [ ] Do the work over SSH; restart the guest display manager if it's a GUI VM. - [ ] Only graceful-shutdown (never hard-reset) a healthy VM, and only with a backup. ## Takeaway A frozen Proxmox console with a living VM is a display problem wearing a disaster costume. The guest is fine — SSH proves it. Reload the console, restart the proxy, do the work over SSH, and keep your hands off `qm reset`. The dangerous mistake isn't the black screen; it's hard-resetting the healthy machine behind it. --- ## Self-Hosted Music Servers Need a Web-First Deployment Path Across Mobile and Desktop Source: https://deploytovps.com/blog/guide/self-host-music-server-web-first-navidrome Published: 2026-06-22 Tags: navidrome, music, self-hosted, streaming, subsonic Your music library should play in a browser, on your phone, and on the desktop — from one self-hosted server. How to deploy Navidrome (or similar) web-first so every client just works. The goal with a self-hosted music server is simple to say and easy to botch: your library should play in a browser at your desk, on your phone on the train, and on the desktop at home — all from one server, without a per-device dance. The way to get there is to deploy **web-first**: a server that streams over HTTP(S) and speaks a standard API that every client app already knows. Navidrome is the popular pick because it does exactly this, but the principles apply to any of them. ## Web-first means one server, many clients A web-first music server gives you two things at once: - **A browser UI** — open a URL, music plays, nothing to install. - **A standard API (Subsonic)** — so a whole ecosystem of mobile and desktop apps connect to *your* server without you writing or maintaining clients. That combination is why you don't need a bespoke app per platform. The server is the product; the clients are interchangeable. ## Library layout the scanner will actually understand Like Jellyfin, a music server reads tags and folder structure, and a messy library scans badly: - Keep consistent `Artist/Album/Track` structure and clean tags (the scanner trusts tags over filenames). - Bind-mount the library read-only if the server only needs to play it — fewer ways to corrupt it. - Match the file ownership to the container user, the [bind-mount ownership rule](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight) that trips up every media app. Store the library on bulk storage and the server's small database on fast disk — the [app-state vs bulk-storage split](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker). ## Transcoding for bandwidth, not for show On a phone over cellular you don't want to stream lossless FLAC. Configure on-the-fly transcoding to a sensible bitrate for mobile, while desktop on the LAN plays originals. Most music transcoding is light on CPU (audio, not video), so this rarely needs hardware help — unlike [Jellyfin's video transcoding](https://deploytovps.com/guide/media-server-proxmox-gpu-passthrough-sizing). ## HTTPS and remote access so the phone works anywhere The phone use case *is* the point, so plan remote access from the start: - Put the server behind HTTPS — Subsonic apps are far happier with a real cert. - Reach it from outside via a [clean domain even behind CGNAT](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443), or keep it private over a VPN if it's just you. - Apply the general [reverse-proxy edge cases](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases) so streaming connections aren't cut by short timeouts. ## Deploy it as a managed app A music server is a great low-stakes first self-hosted app — and deploying from a template gets you a working, persistent, TLS-ready instance without hand-wiring. ![ServerCompass selecting the Navidrome template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/navidrome/04-select-navidrome-template.png) *Deploying Navidrome from a template in [ServerCompass](https://servercompass.app) — web UI, database, and storage provisioned together so every Subsonic client connects on day one.* ## Checklist - [ ] Library tagged and structured for a clean scan; mounted read-only. - [ ] DB on fast disk, library on bulk storage. - [ ] Mobile transcoding configured; desktop plays originals. - [ ] HTTPS + a remote-access path planned for the phone. - [ ] Deployed as a managed/template app, not hand-wired. ## Takeaway Self-hosted music works when you deploy web-first: one server that streams in the browser and speaks a standard API to every mobile and desktop client. Get the library clean, split storage, transcode for bandwidth, and put HTTPS and remote access in from the start — then your whole collection just plays, everywhere, from a server you own. --- ## The Last Mile of Alerting: Where Uptime Kuma, n8n Triggers, and Your Pager Disagree Source: https://deploytovps.com/blog/guide/alerting-last-mile-uptime-kuma-n8n-pager Published: 2026-06-22 Tags: alerting, uptime-kuma, n8n, monitoring, on-call Your monitoring detects the outage perfectly and the alert still never reaches you. The last mile — delivery, dedup, escalation — is where homelab alerting actually breaks. Most homelab monitoring is excellent at the part that's easy. Uptime Kuma notices the service is down within seconds; an n8n workflow fires; a webhook goes out. And then nothing reaches you, or fifty things do, or the one that mattered arrived at 3am buried under forty that didn't. Detection is solved. The **last mile** — getting the *right* alert to a human who can act, exactly once, with enough context — is where homelab alerting actually fails. ## The three last-mile failures **Silence.** The notification channel was misconfigured, the token expired, or the one provider you used was itself part of the outage. You find out the service was down for six hours from a family member, not your phone. **Flood.** Every check on every host alerts on every blip through every channel. Within a week you've muted the lot, which means the real outage is now also silent — just voluntarily. **No context.** "Service down" with no indication of *which* service, how bad, or what to do. You're now SSHing into boxes to figure out what your own alert meant. ## Fix 1: make the delivery path itself reliable The alert path must not share fate with the thing it watches. Run the monitor somewhere independent of the monitored box, and use a delivery channel that survives your network being down (a push service or SMS, not only a self-hosted chat that's on the same VPS). Test it deliberately: trip a check on purpose and confirm the message lands on your actual phone. ## Fix 2: deduplicate and escalate, don't just notify n8n is perfect for this middle layer. Instead of "alert → channel," build "alert → dedup → severity → escalate": - **Dedup**: collapse repeated firings of the same check into one notification with a count. - **Severity**: a warning (disk 80%) and a page (service down) are different events. - **Escalate**: if a page isn't acknowledged in N minutes, widen the channel or notify a second person — the [bus-factor fix](https://deploytovps.com/guide/self-host-runbooks-bus-factor-oncall) in alert form. ## Fix 3: route by severity to different places The single highest-leverage change is to stop sending everything to one channel. Warnings to a quiet log channel; real pages to the channel that wakes you. ![ServerCompass routing alerts to Discord and Telegram by severity](https://assets.stoicsoft.com/posts/servercompass/alert-routing-discord-telegram-severity-templates.png) *Severity-based routing in [ServerCompass](https://servercompass.app) — warnings and wake-me-up pages go to different destinations with templated context, so the noisy ones never train you to ignore the important ones.* ## Fix 4: put the fix in the alert Every page should carry enough to start acting: which host, which service, the current value, and a link to the runbook. An alert that links straight to a [rollback playbook with the context in one place](https://deploytovps.com/guide/rollback-plus-alert-context-same-place) turns a wake-up into a 60-second action. The broader goal is [low-noise uptime checks](https://deployhandbook.com/best/low-noise-uptime-checks-homelab) — every alert worth the interruption. ## Last-mile checklist - [ ] Monitor runs independent of the monitored host. - [ ] Delivery channel survives your network being down; tested end to end. - [ ] Dedup so repeats collapse to one. - [ ] Severity split: warnings vs pages, different channels. - [ ] Escalation if a page isn't acknowledged. - [ ] Context + runbook link in every page. ## Takeaway You don't have a detection problem; you have a delivery problem. Make the alert path independent and tested, dedup and escalate in the middle, route by severity, and carry the fix in the message. That's the last mile — and it's the only mile your sleeping self actually experiences. --- ## Beginners Don't Want a Docker Tutorial — They Want FreshRSS and n8n Running Source: https://deploytovps.com/blog/guide/app-first-install-paths-freshrss-n8n Published: 2026-06-22 Tags: freshrss, n8n, beginners, self-hosted, docker The fastest way to lose a new self-hoster is to make them learn Docker before they get anything working. App-first install paths put the win first and the concepts later. There are two ways to introduce someone to self-hosting. The common one starts with Docker: images, volumes, networks, Compose — and somewhere around the third concept, the person who just wanted an RSS reader quietly closes the tab. The better one is **app-first**: get FreshRSS or n8n actually running, with TLS and a backup, in the first session — *then* explain what a volume is once they care. Motivation follows a working app, not a finished tutorial. ## Why Docker-first loses beginners Docker is the right tool and the wrong starting point. A newcomer has to hold five abstractions in their head before any payoff, and each is a chance to fail in a way they can't yet diagnose. The drop-off isn't about intelligence; it's about ordering. You don't teach someone to drive by starting with the engine. ## App-first: the win comes first Flip the order. Session one ends with "FreshRSS is at `https://reader.mydomain.com`, it has my feeds, and it's backed up." The path that makes that possible: - **Deploy from a template**, so the app, its database, and TLS come up together without hand-edited YAML. - **A real hostname with HTTPS** from the start, not an `http://ip:port` that feels broken on the phone. - **A backup configured before real data goes in.** ![ServerCompass selecting the FreshRSS template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/freshrss/04-select-freshrss-template.png) *FreshRSS deployed from a template in [ServerCompass](https://servercompass.app) — the beginner picks the app and gets a working, TLS-enabled instance instead of a Docker lesson.* n8n is the same story: a template deploy lands a working automation tool with persistence and HTTPS, so the first thing the newcomer does is build a workflow — the actual fun — not debug a port mapping. (When they're ready to run n8n seriously, the [n8n production-readiness checklist](https://deploytovps.com/guide/n8n-self-host-production-readiness-checklist) is the next step.) ## Teach the concepts *after* the win Once the app is running and they're attached to it, the concepts land because they're now answers to real questions: - "Where do my feeds live?" → volumes. - "Why did it survive a reboot?" → restart policy + persistence. - "How do I not lose this?" → backups and a [restore drill](https://deploytovps.com/guide/docker-volume-restore-drill). - "How is it private?" → TLS and access control. Each concept is now motivated. That's the difference between learning and bouncing. ## The app-first checklist - [ ] App reachable at a real HTTPS hostname in session one. - [ ] Template/managed deploy, not hand-edited Compose, for the first install. - [ ] Backup configured before real data is added. - [ ] Concepts introduced as answers to questions the working app raises. The same gentle on-ramp works for heavier apps too — see [an easy Immich install path](https://deploytovps.com/guide/immich-easy-install-path-for-nontechnical-users). ## Takeaway Beginners don't bounce off self-hosting because it's hard; they bounce because the concepts come before the payoff. Lead with a running FreshRSS or n8n — TLS and backups included — and teach Docker afterward, when the questions are theirs. Win first, theory second. --- ## When a Budget Homelab Beats a Pile of Small VPS Bills — and When It Doesn't Source: https://deploytovps.com/blog/guide/budget-homelab-vs-small-vps-mixed-stack Published: 2026-06-22 Tags: homelab, vps, cost, mini-pc, self-hosted Beginners are swapping $5/month VPS instances for one mini PC running a mixed stack. Sometimes that's smart, sometimes it just moves the cost to your time. How to decide. There's a popular move right now: a beginner adds up their $5-here, $7-there VPS bills, sees $40/month, and buys a mini PC to run "everything" at home instead. Sometimes that's a genuinely great trade. Sometimes it just converts a money cost into a time-and-reliability cost they didn't price in. The decision is real and worth making deliberately rather than by vibes. ## The honest cost comparison A budget homelab box isn't free just because there's no monthly invoice: - **Power** — a mini PC at 15–30W runs ~$3–7/month depending on rates; a hungrier tower far more. Spinning disks and a NAS add to it. - **Your time** — patching, backups, and the 2am page are now yours alone (the [bus-factor problem](https://deploytovps.com/guide/self-host-runbooks-bus-factor-oncall) is a homelab tax). - **Uptime** — home internet and home power have outages a datacenter doesn't. Your residential IP is often behind [CGNAT](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443), complicating public access. - **Up-front hardware** — amortize it; a $300 mini PC over 3 years is ~$8/month before power. ## When the homelab wins - You're running **bulk storage or media** (Jellyfin, Immich, big libraries) — paying a VPS for terabytes is genuinely wasteful, and a box at home with disks is far cheaper. - The workloads are **private** (don't need public uptime) — internal dashboards, backups, experiments. - You **want to learn** the hardware and don't mind being on-call. - You'll consolidate enough services that the per-VPS math clearly loses. ## When small VPSes win - The service is **public and needs uptime** — a status page, a small site, anything users depend on. A datacenter's power, network, and real IP are worth the few dollars. - You value **not being the on-call**. Managed-ish small VPSes fail less and recover without you. - The workload is tiny and **latency/availability matter more than storage**. ## The hybrid most people should actually run The framing isn't all-or-nothing. The sensible middle: - **Homelab** for storage, media, private tools, and anything bandwidth-heavy. - **A small VPS** for the public-facing edge — the reverse proxy, the status page, the things that must answer when your home internet doesn't. It can even be the relay that reaches your home services past CGNAT. That split puts each workload where it's cheapest *and* most reliable, instead of forcing everything onto one side. Before you trust either box with real load, [benchmark what it actually does](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs) rather than trusting the spec sheet — a cheap VPS and a mini PC can both surprise you in either direction. For a first home box, the [Pi-plus-NAS practical starter](https://deploytovps.com/guide/pi-plus-nas-practical-self-host-starter) is a good low-commitment shape. ## Decision checklist - [ ] Counted *total* cost: VPS bills vs hardware + power + your time. - [ ] Public/uptime-critical workloads → keep on a VPS. - [ ] Storage/media/private workloads → homelab. - [ ] Considered a hybrid: home for bulk, VPS for the public edge. - [ ] Benchmarked the target before trusting it. ## Takeaway A budget homelab can beat a stack of small VPS bills — for storage, media, and private workloads where you accept being the on-call. For public, uptime-critical services, a few dollars of VPS buys reliability you can't replicate at home. Most people's best answer is both: homelab for the heavy private stuff, a small VPS for the edge that has to stay up. --- ## Media Servers Keep Hitting the Same Three Walls: Proxmox Passthrough, GPU Sizing, Storage Source: https://deploytovps.com/blog/guide/media-server-proxmox-gpu-passthrough-sizing Published: 2026-06-22 Tags: proxmox, jellyfin, immich, gpu-passthrough, media-server Jellyfin and Immich on Proxmox run into the same trio every time — GPU passthrough that won't stick, transcoding sized wrong, and storage that fights the hypervisor. Clear each wall. Run a media server on Proxmox long enough and you'll meet the same three walls everyone does, in the same order: getting a GPU into the VM or container, sizing transcoding so it doesn't choke, and laying out storage so the media doesn't fight the hypervisor. They're not Jellyfin or Immich bugs — they're the friction of putting hardware-hungry media apps on a virtualization host. Here's how to clear each. ## Wall 1: GPU passthrough that actually sticks You need the GPU's hardware encoder reachable from the app. Two paths on Proxmox: - **VM with PCIe passthrough** — pass the whole GPU to a VM. Strong isolation; the host can't use that GPU anymore, and you'll need IOMMU enabled and the GPU bound to `vfio-pci`. Best when one VM owns media. - **LXC with device passthrough** — share the host's GPU into an *unprivileged* container by mapping the render device and matching group IDs. Lighter, and lets multiple containers share one GPU — the usual choice for Jellyfin in LXC. The common "it worked then broke after reboot" trap is non-persistent setup: bind the device and load modules on boot, or the passthrough silently reverts. This is the same host-prerequisite class as [the hidden prerequisites Compose scripts miss](https://deploytovps.com/guide/docker-compose-hidden-prerequisites-preflight). ## Wall 2: sizing the transcode, not guessing Hardware transcoding turns a melting CPU into a quiet GPU — but only if it's sized for real concurrency: - Count **simultaneous transcodes**, not total users. Four people, but only two transcoding at once, is a two-stream sizing problem. - **Direct play beats transcoding.** Match your library formats to your clients and most sessions won't transcode at all — the cheapest optimization there is. - Immich uses the GPU too (ML, thumbnails). If Jellyfin and Immich share one GPU, their peaks can collide — the [capacity-planning logic for self-hosted AI](https://deploytovps.com/guide/self-host-ai-capacity-planning-host-sizing) applies to shared media GPUs as well. ## Wall 3: storage that doesn't fight the hypervisor Media is terabytes; the hypervisor's fast disk is precious. Keep them separate: - **App state** (Jellyfin's database, Immich's Postgres) on the fast VM/LXC disk. - **Media library** on a dedicated pool/share, passed in cleanly — not stored on the same dataset Proxmox uses for VM disks. - Avoid the ZFS-on-ZFS and ownership tangles covered in [a sane Proxmox storage plan](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups). ## Order of operations 1. Enable IOMMU; decide VM-passthrough vs LXC-device-share. 2. Make the passthrough persistent across reboot. 3. Confirm the app sees the encoder (`/dev/dri` present, ffmpeg lists the hw encoder). 4. Size transcoding by concurrent streams; prefer direct play. 5. Split app state (fast disk) from library (dedicated pool). ## Takeaway Media on Proxmox is three predictable walls, not a hundred random ones. Get the GPU passed through *persistently*, size transcoding by real concurrent streams, and keep the library off the hypervisor's disks. Clear those three and Jellyfin and Immich are genuinely pleasant on Proxmox. --- ## Nextcloud in Production Needs Preflights for CPU Spikes, Backups, and SMTP Source: https://deploytovps.com/blog/guide/nextcloud-production-preflight-cpu-backups-smtp Published: 2026-06-22 Tags: nextcloud, production, backups, smtp, performance A fresh Nextcloud feels fine and then chokes — preview generation pins the CPU, the backup was never real, password resets bounce. The preflights that make it production-grade. A new Nextcloud feels great for a week. Then real use arrives — a few people uploading photos, syncing folders, sharing links — and the cracks show in a specific order: the CPU pins for no obvious reason, a "backup" turns out to be a folder copy that can't actually restore, and a user's password reset email never arrives. None of these show up in a quick test; all of them show up in production. Run these preflights before you rely on it. ## Preflight 1: tame preview generation The number-one Nextcloud "why is the CPU at 100%?" cause is on-demand preview generation — every thumbnail rendered when first viewed, in bursts. Two fixes: - **Pre-generate previews** on a schedule (the Preview Generator app + a cron job) so viewing is cheap. - **Limit preview sizes/formats** so Nextcloud isn't rendering enormous thumbnails of RAW files. Pair this with explicit container memory limits so a preview storm can't take the whole box, the same [resource-limit discipline](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts) every busy app needs. ## Preflight 2: use cron, not AJAX, for background jobs Nextcloud's default "AJAX" background-job mode only runs jobs when someone loads a page — so cleanup, notifications, and preview jobs lag or never run. Switch to **system cron** (every 5 minutes) so background work happens reliably whether or not anyone's looking. This single change fixes a surprising amount of "Nextcloud feels slow / stale." ## Preflight 3: a backup that can actually restore A Nextcloud backup is three things kept consistent: the **database**, the **data directory**, and **`config.php`**. Copying just the files gives you an instance that can't see its own data (the [app-owned-state problem](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state)). Put it in maintenance mode, dump the database, copy data + config, and — the part everyone skips — **restore it once** to confirm it works. An untested backup is a hope, not a plan. ## Preflight 4: configure SMTP before you need it Nextcloud needs working outbound email for password resets, share notifications, and security alerts. Unconfigured, those silently fail and you discover it when a user is locked out. Set SMTP, send a test mail, and confirm it lands (not in spam). If you're running your own mail path, the [mail-server domain prerequisites](https://deploytovps.com/guide/mail-server-domain-prerequisites) (SPF/DKIM/DMARC) are what keep those mails from being dropped. ## Preflight 5: deploy on the right foundation Many of these are easy to get right when the instance is provisioned properly to begin with — database, cron, and TLS configured as part of the deploy rather than bolted on later. ![ServerCompass selecting the Nextcloud template before deploy](https://assets.stoicsoft.com/template-guides/servercompass/nextcloud/04-select-nextcloud-template.png) *Deploying Nextcloud from a template in [ServerCompass](https://servercompass.app) — database, persistent storage, and TLS provisioned together so the production preflights start from a sane baseline.* ## Production checklist - [ ] Previews pre-generated on a schedule; sizes limited. - [ ] Background jobs on system cron, not AJAX. - [ ] Backup covers DB + data + config, and has been restored once. - [ ] SMTP configured and a test mail confirmed delivered. - [ ] Memory limits set; HTTPS with auto-renewal. ## Takeaway Nextcloud is production-ready, but not by default. Pre-generate previews, move jobs to cron, make the backup one you've actually restored, and wire SMTP before a locked-out user finds it broken. Five preflights stand between "feels fine in a demo" and "trustworthy with the household's files." --- ## Proxmox Backup Planning Has Moved From 'Install Tutorials' to Selective Offsite Source: https://deploytovps.com/blog/guide/proxmox-backup-selective-offsite-planning Published: 2026-06-22 Tags: proxmox, backups, offsite, pbs, retention You've got Proxmox Backup Server running — now the real question. Not everything deserves an offsite copy, and backing up everything wastes space and money. How to back up selectively. The Proxmox backup conversation has matured. A year ago it was "how do I install Proxmox Backup Server?" Now PBS is running and the interesting question is the one tutorials skip: *what actually deserves an offsite copy, and how long do you keep it?* Backing up everything, forever, to a second location is simple and wasteful. Backing up nothing important offsite is a disaster waiting for a fire. The answer is selective, tiered planning. ## Local PBS is not offsite First, the distinction that trips people up: a Proxmox Backup Server in the same room as the host it backs up protects you from a bad upgrade or a fat-fingered delete — not from theft, fire, flood, or ransomware that reaches both. The classic **3-2-1** rule still holds: at least one copy offsite, on different media. Local PBS is your fast-restore tier; offsite is your disaster tier. You need both, but not for everything. ## Tier your VMs by recoverability, not by size Sort every VM/CT into three buckets by *how you'd recover it*: - **Irreplaceable data** (photos, documents, app databases) → local PBS **and** offsite. This is the data that's gone forever if both copies die. - **Reproducible-but-painful** (configured app servers) → local PBS, plus the *config* in a repo so a [from-scratch rebuild](https://deploytovps.com/guide/vps-rebuild-automation-compose-traefik-mail) can recreate them. Offsite optional. - **Trivially reproducible** (a stateless container you can redeploy from a template in minutes) → maybe no backup at all. Don't pay to back up what you can recreate. Most homelabs discover the offsite set is small — which is exactly the point. Offsite the irreplaceable, reproduce the rest. ## Plan retention deliberately Keep-everything-forever fills disks and budgets. A workable scheme: - Frequent recent backups (daily for a week), thinning to weekly and monthly. - PBS's prune/retention settings encode this; set them so old backups age out automatically. - Match retention to how far back a problem might go unnoticed — if a corruption could hide for a month, keep a month. ## Verify, don't assume A backup you've never restored is a guess. PBS can verify backup integrity on a schedule — enable it. Then, periodically, do a **real restore** of a tiered VM to a scratch target. The same [restore-drill discipline](https://deploytovps.com/guide/docker-volume-restore-drill) that applies to Docker volumes applies to whole VMs, and it's what separates "I have backups" from "I can recover." ## Planning checklist - [ ] Every VM/CT tiered: irreplaceable / reproducible-painful / trivial. - [ ] Irreplaceable data has a real offsite copy (3-2-1). - [ ] Reproducible servers have config-as-code, not just images. - [ ] Retention/prune configured so old backups age out. - [ ] PBS verification on; a real restore rehearsed periodically. ## Takeaway Past the install, Proxmox backup is a planning problem, not a tooling one. Tier by recoverability, send only the irreplaceable offsite, let config-as-code handle the reproducible, set retention so it self-prunes, and prove restores work. That's the difference between a backup *setup* and a backup *plan*. --- ## Proxmox Migrations Need Confidence Checks for Host Crashes and Storage Latency Source: https://deploytovps.com/blog/guide/proxmox-migration-confidence-checks Published: 2026-06-22 Tags: proxmox, migration, cluster, storage, reliability Moving VMs and containers between Proxmox hosts works in the demo and bites in production — a crash mid-migration, latency that corrupts, a cluster that won't quorum. The checks that de-risk it. Proxmox makes moving a VM between hosts look like a button. In a calm demo it is. In production — under load, across imperfect storage, on hardware that can crash — that button has failure modes that turn a routine move into a recovery. The fix isn't to avoid migrations; it's to run a few confidence checks first so the move is boring. This pairs with [a sane Proxmox storage plan](https://deploytovps.com/guide/proxmox-storage-plan-truenas-lxc-nfs-backups); the plan is the foundation, these are the pre-move checks. ## What actually goes wrong - **A host crashes mid-migration**, leaving a VM in a half-moved state — disk on one node, definition on another. - **Storage latency** during the transfer stalls or corrupts a busy database that can't tolerate the pause. - **Quorum loss** in a small cluster: lose one node in a two-node cluster and the survivors won't make decisions, freezing everything. - **A "live" migration that wasn't** — shared storage assumptions were wrong, so it silently fell back to a slow, risky copy. ## Confidence check 1: a current, tested backup before you move The non-negotiable. Whatever the migration does, you must be able to get the VM back. A Proxmox Backup Server restore you've actually performed — not just scheduled — is the floor. Migration is not a substitute for backup; it's an operation that *needs* one. (Same rule as any [restore drill](https://deploytovps.com/guide/docker-volume-restore-drill).) ## Confidence check 2: know your storage model Live migration is cheap when both hosts see the same shared storage and only RAM moves. It's expensive and riskier when the disk has to copy too. Before you click: - Confirm whether the VM's storage is **shared** (Ceph/NFS both nodes see) or **local** (disk must copy). - For local-disk moves, expect a long transfer and schedule a window — don't do it under peak load. - Watch storage latency; a busy database VM may need to be **stopped** for a clean cold migration rather than risk a live one. ## Confidence check 3: quorum before you remove anything In a cluster, check `pvecm status` and confirm you'll *keep* quorum through the operation. Two-node clusters need a qdevice/witness or you'll deadlock the moment one node is down. Never start a migration that could drop you below quorum. ## Confidence check 4: a rollback that's real Decide, before you start, exactly how you'd undo it: the backup to restore from, the node to restore to, and how you'd repoint access. Writing it down turns a mid-migration crash from a panic into [following a rollback playbook](https://deploytovps.com/guide/rollback-plus-alert-context-same-place). When an OS-EOL forces the move, the [rebuild-and-reattach approach](https://deploytovps.com/guide/os-eol-migration-multi-app-vps) is often safer than an in-place shuffle. ## Safe sequence 1. Backup the VM and confirm a restore works. 2. Identify storage model (shared vs local) and pick live vs cold. 3. Confirm cluster quorum survives the move. 4. Pick a low-load window; stop very busy DB VMs for a cold move. 5. Migrate, verify on the new host, then retire the old copy. ## Takeaway Proxmox migrations are safe when you've removed the four ways they bite: no tested backup, unknown storage model, lost quorum, and no rollback. Check those first and moving a VM between hosts goes back to being the boring button it looks like. --- ## Docker Deployment Is Shifting Toward Testing the Exact Image Before Rollout Source: https://deploytovps.com/blog/guide/test-exact-docker-image-before-rollout Published: 2026-06-22 Tags: docker, ci-cd, image-digest, deployments, testing 'Works on my machine' is back as 'works on the tag I tested, not the one that deployed.' Pinning and promoting the exact image digest is how teams stop deploying surprises. The oldest bug in deployment — "works on my machine" — has a modern form: "works on the image I tested, not the one that actually deployed." It happens because a tag like `:latest` or `:v2` is a *moving pointer*, not a fixed artifact. You test the image behind the tag on Monday, the tag gets repointed to a rebuild on Tuesday, and Wednesday's deploy ships something you never tested. The industry's quiet shift is toward testing and deploying the **exact same immutable image**, identified by digest, all the way through. ## Tags lie; digests don't `nginx:1.25` can point to different bytes over time as it's rebuilt for security patches. A **digest** (`nginx@sha256:abc...`) is content-addressed — it always refers to the exact same image. The principle: build once, get a digest, and reference *that digest* from test through production. The tag is for humans; the digest is for deploys. ## Promote the artifact, don't rebuild it The anti-pattern is building separately for staging and production — now you've tested one build and shipped another. Instead, **promote**: 1. Build the image once in CI; record its digest. 2. Run tests against that digest. 3. Deploy that *same* digest to production — no rebuild. If production rebuilds from the Dockerfile, you've reintroduced the very drift you were avoiding. This is the runtime cousin of [Compose drift](https://deploytovps.com/guide/docker-compose-drift-preflight): there the config moves under you; here the image does. ## Smoke-test the exact image before it serves traffic Pinning gets you *the same* image; a smoke test confirms it actually works in this environment before users hit it: - Start the pinned image, hit its health endpoint and one real path. - For realtime apps, confirm the live path too — the [WebSocket-specific checks](https://deploytovps.com/guide/websocket-checks-before-rolling-realtime-deploys) that HTTP probes miss. - Only flip traffic after the smoke test passes against the exact digest you're about to promote. ## Keep the previous digest for instant rollback Because digests are immutable, rollback is trivial *if you recorded the last good one*: repoint to the previous digest and you're back to a known artifact, not a hopeful rebuild. Keep the last few good digests noted alongside the deploy — pair it with a [rollback playbook that has the context in one place](https://deploytovps.com/guide/rollback-plus-alert-context-same-place) and recovery is seconds. ## Practical checklist - [ ] Reference images by digest in the deploy, not a moving tag. - [ ] Build once in CI; record the digest. - [ ] Test and deploy the *same* digest — never rebuild for prod. - [ ] Smoke-test the pinned image before flipping traffic. - [ ] Record the previous good digest for instant rollback. ## Takeaway The reason a deploy "suddenly broke" is usually that you deployed an image you never tested, because a tag moved. Pin by digest, promote the one tested artifact unchanged, smoke-test that exact image, and keep the last good digest for rollback. Test what you ship, ship what you tested — by digest, not by hope. --- ## Why VPS Rebuild Automation Feels Fragile — and How to Make It Trustworthy Source: https://deploytovps.com/blog/guide/vps-rebuild-automation-compose-traefik-mail Published: 2026-06-22 Tags: automation, ansible, traefik, mail, reproducibility You automate the VPS rebuild with Ansible and Compose, and it still feels like it might not come back. The fragile parts are usually Traefik, mail, and state — here's how to harden them. The dream is a single command that rebuilds your whole VPS from scratch: Ansible provisions the box, Compose brings up the apps, Traefik gets certs, mail flows, done. The reality is that even when it works, it *feels* fragile — you're never quite sure it'll come back identical, so you avoid testing it, which makes it more fragile. The fragility isn't random. It clusters in three places: TLS/edge, mail, and state. Harden those and a from-scratch rebuild becomes something you trust enough to actually run. ## Why "automated" still feels risky Provisioning packages and writing config files is the easy, idempotent 80%. The scary 20% is everything with an *external dependency or persistent state*: certificates issued by a rate-limited CA, mail that depends on DNS and reputation, and data that must survive the rebuild. Those don't reset cleanly, so a naive rebuild can hit a Let's Encrypt rate limit, send mail that bounces, or come up with empty databases. ## Fragile part 1: Traefik and TLS Certificate issuance is the classic rebuild trap. Rebuild too often and Let's Encrypt rate-limits you; lose the `acme.json` and every cert reissues at once. - **Persist `acme.json`** on a volume that survives rebuilds, so certs are reused, not reissued. - Use the **DNS-01 challenge** for wildcard/many-subdomain setups so issuance doesn't depend on the box being publicly reachable mid-rebuild. - Make sure renewal reloads cleanly — the exact failure in [Let's Encrypt renew-and-reload behind a proxy](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). ## Fragile part 2: mail Mail is fragile because deliverability lives in DNS and reputation, not in your playbook. A rebuilt server with default mail config sends mail that lands in spam or is rejected outright. - Keep **SPF, DKIM, and DMARC** as code/DNS that the rebuild references — see the [mail-server domain prerequisites](https://deploytovps.com/guide/mail-server-domain-prerequisites). - Persist DKIM keys across rebuilds (rotating them on every rebuild breaks alignment). - After a rebuild, **send a test message and check it lands** before trusting password resets and alerts to it. ## Fragile part 3: state Automation that rebuilds the *machine* but forgets the *data* is worse than no automation — it confidently produces an empty box. Separate cleanly: - **Code/config** → in the repo, fully reproducible. - **Data** → in volumes/backups, restored as a deliberate step, never assumed. The rebuild playbook should *restore* state, and you should have [drilled that restore](https://deploytovps.com/guide/docker-volume-restore-drill). This is the same code-vs-state line that makes [OS-EOL rebuilds](https://deploytovps.com/guide/os-eol-migration-multi-app-vps) safe. ## Make it trustworthy by testing it The reason rebuild automation feels fragile is that it's rarely exercised. Fix that: - Rebuild onto a **throwaway VPS** regularly and verify every app comes up with its data. - Keep the playbook **idempotent** — running it twice changes nothing the second time. - Pin versions so "rebuild" doesn't silently mean "upgrade everything" and inherit [Compose drift](https://deploytovps.com/guide/docker-compose-drift-preflight). ## Checklist - [ ] `acme.json` persisted; DNS-01 for wildcards; renewal reloads. - [ ] SPF/DKIM/DMARC as code; DKIM keys persisted; post-rebuild mail test. - [ ] Code in repo, data in backups; restore is an explicit, drilled step. - [ ] Playbook idempotent and version-pinned. - [ ] Rebuild rehearsed on a throwaway box, end to end. ## Takeaway Rebuild automation feels fragile because the stateful, externally-dependent parts — TLS, mail, data — don't reset cleanly. Persist certs and DKIM keys, treat data restore as an explicit step, pin versions, and rehearse the whole thing on a throwaway box. Do that and "rebuild the VPS" becomes a command you trust instead of one you're afraid to run. --- ## The Hidden Prerequisites Docker Compose Setup Scripts Keep Missing Source: https://deploytovps.com/blog/guide/docker-compose-hidden-prerequisites-preflight Published: 2026-06-22 Tags: docker-compose, preflight, self-hosted, permissions, sysctl A Compose file isn't a full deployment. Setup scripts skip the kernel modules, sysctls, host directories, and permissions an app needs — and the failure shows up after 'it started'. `docker compose up -d` finishes, everything says `Started`, and ten minutes later the app is in a crash loop or silently broken. The Compose file did its job — it's the *host* that wasn't ready. A surprising number of self-hosted apps depend on prerequisites that live outside the YAML entirely, and the one-line setup scripts that promise "just run this" almost always skip them. This is the distinct sibling of [Compose drift over time](https://deploytovps.com/guide/docker-compose-drift-preflight): drift is about the config changing under you; this is about what was never set on the host in the first place. ## The prerequisites that aren't in the Compose file **Kernel parameters (sysctls).** The classic is Elasticsearch/OpenSearch needing `vm.max_map_count=262144` — without it the container exits immediately. Others need `net.core.somaxconn` raised, or `vm.overcommit_memory` for Redis. These are host settings; Compose can request some via `sysctls:` but many must be set on the host and made persistent in `/etc/sysctl.d/`. **Kernel modules.** Apps doing networking or storage tricks (WireGuard, some VPN containers, ZFS tooling) need a module loaded on the host. If it's missing, the container starts and the feature just doesn't work. **Host directory ownership.** Bind-mounted directories must be owned by the UID the container runs as. Create the dir as root, and the app — running as uid 1000 — can't write to its own data path. This is the quiet "it started but won't save anything" failure. **Ports already in use.** A port the app wants is taken by another service, so it binds partially or not at all. On a multi-app box this is common enough to deserve its own treatment — see [multi-app VPS port collisions](https://deploytovps.com/guide/multi-app-vps-port-collisions). **Required environment / secrets.** A missing `.env` value doesn't always stop startup; sometimes the app boots with an insecure default or a blank admin password. Worse than crashing, because it looks fine. ## Run a preflight, not a post-mortem The fix is to check these *before* `up`, not diagnose them after: - [ ] Required sysctls set on the host and persisted (`vm.max_map_count`, etc.). - [ ] Needed kernel modules loaded and set to load on boot. - [ ] Bind-mount directories created **and chowned** to the container's UID/GID. - [ ] Target ports confirmed free. - [ ] Every required env var present; no app left on a default password. - [ ] Disk headroom on the data volume. A two-minute preflight turns "started but broken" into "actually working." ## Templates encode the prerequisites The reason a one-line script misses these is that it only knows about the container, not the host. A managed deployment that's built per-app can set the sysctls, create the directories with the right ownership, and pick free ports as part of bringing the app up. ![ServerCompass reviewing generated settings before deploying an app](https://assets.stoicsoft.com/template-guides/servercompass/n8n/04-select-n8n-template.png) *Deploying from a per-app template in [ServerCompass](https://servercompass.app) — host prerequisites, volumes, and ports are resolved as part of the deploy instead of being left to a generic script.* ## Takeaway A Compose file describes containers; it doesn't describe the host they need. Most "it started and then broke" incidents are a missing sysctl, an unloaded module, a directory the app can't write to, or a port already taken. Run the preflight before you bring the stack up, and the app that "should just work" actually does. --- ## Homelab Database Sprawl: The Problem That Arrives After Docker App Sprawl Source: https://deploytovps.com/blog/guide/homelab-database-sprawl-consolidation Published: 2026-06-22 Tags: postgresql, mariadb, databases, docker, homelab Every self-hosted app ships its own Postgres or MySQL, and soon you're running eight database containers you can't name. How to consolidate without coupling apps together. You solved Docker app sprawl — everything's in Compose, everything starts on boot. Then you count the database containers and there are nine: a Postgres for Immich, another for a different app pinned to an older major version, two MariaDBs, a Mongo you forgot about, and a Redis per app. Each one has its own volume, its own backup story (or none), and its own memory footprint. That's database sprawl, and it's the second-order cost of one-Compose-file-per-app. ## Why it happens — and why it's not automatically bad Apps bundle a database so they "just work." That's a feature for getting started and a liability at scale: every engine is a thing to patch, back up, and keep from eating RAM. But the naive fix — "one big shared database for everything" — can be worse, because it couples unrelated apps: now an upgrade for one app's schema requires a database version another app can't run on. The goal isn't *one* database. It's *fewer, deliberate* databases. ## When to share an instance Sharing **one Postgres instance with separate databases/roles per app** is the sweet spot when: - The apps tolerate the same major version (check each app's supported range first). - You want one place to tune memory, one backup job, one monitoring target. - The apps aren't so critical that you need independent blast radius. Create a database and a least-privilege role per app on the shared instance. You get consolidation *without* the apps sharing tables or users. ## When to keep them isolated Keep a dedicated database container when: - The app pins a specific (often older) engine version that would block others. - It's your most critical data and you want it to fail and upgrade independently. - The engine differs (don't try to make one box be Postgres, MySQL, *and* Mongo — run the engine each app actually needs). ## Tame the sprawl you keep For whatever set you land on: - **Pin and record versions.** An app and its database major version are a unit; write it down so an upgrade doesn't surprise you (the same discipline that prevents [Compose drift](https://deploytovps.com/guide/docker-compose-drift-preflight)). - **One backup strategy, every engine.** Logical dumps on a schedule, shipped off-box, with a tested restore. Snapshots of the volume are a bonus, not the plan. - **Cap memory per engine** so a runaway query can't starve the host — same logic as [sizing self-hosted AI and search apps](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts). ## Deploy databases as first-class apps, not afterthoughts Treating the database as a managed app — with its own template, volume, and version — beats letting it ride along invisibly inside another app's Compose file. ![ServerCompass selecting the PostgreSQL template from its app catalog](https://assets.stoicsoft.com/template-guides/servercompass/postgresql/04-select-postgresql-template.png) *Standing up a shared PostgreSQL as its own managed service in [ServerCompass](https://servercompass.app) — explicit version, volume, and backup target instead of a database buried inside an app stack.* ## See every engine in one place The reason sprawl hurts is that it's invisible until something breaks. A single view of which databases exist, their versions, disk, and backup status turns "I think nine?" into a list you can act on — pair it with [one dashboard for app health](https://deployhandbook.com/best/single-dashboard-app-health-self-host). ## Takeaway Database sprawl is the predictable sequel to app sprawl. Don't collapse to one shared everything, and don't keep nine. Share a single well-tuned instance where versions align, isolate the few that genuinely need it, and give the whole set one backup story you've actually tested. --- ## Immich Behind a Reverse Proxy: The App-Specific Edge Cases That Break It Source: https://deploytovps.com/blog/guide/immich-behind-reverse-proxy-edge-cases Published: 2026-06-22 Tags: immich, reverse-proxy, nginx, traefik, self-hosted Immich works on localhost and then fails behind nginx or Traefik — large uploads rejected, WebSockets dead, the app convinced it lives on the wrong URL. The Immich-specific fixes. Immich is one of those apps that runs perfectly on `localhost:2283` and then falls apart the moment you put it behind a reverse proxy. The symptoms are specific and repeatable: uploading a video fails at a certain size, the timeline won't live-update, the mobile app can't log in, or Immich keeps generating links to the wrong host. None of these are reverse-proxy bugs in general — they're the handful of things Immich in particular needs the proxy to get right. The generic guidance in [reverse proxy at the edge vs end-to-end TLS](https://deploytovps.com/guide/reverse-proxy-edge-tls-vs-end-to-end) still applies; this is the Immich-specific layer on top. ## Edge case 1: large uploads rejected Phone videos are big, and proxies cap request bodies by default. nginx returns `413 Request Entity Too Large` at 1 MB until you raise it: ```nginx client_max_body_size 50000M; ``` On Traefik there's no body cap by default, but if you've set `maxRequestBodyBytes` anywhere, raise it. This single setting is the most common "Immich uploads fail" cause. ## Edge case 2: WebSockets and the live timeline Immich uses WebSockets for live updates and job status. A proxy that doesn't forward the `Upgrade`/`Connection` headers leaves the UI looking frozen. For nginx: ```nginx proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Traefik handles WebSocket upgrades automatically, so if live updates fail there, suspect a timeout cutting the long-lived connection rather than the upgrade itself. ## Edge case 3: timeouts on long operations Thumbnail generation, large uploads, and ML jobs hold connections open. A 60-second proxy read timeout cuts them off mid-flight. Raise `proxy_read_timeout`/`proxy_send_timeout` on the Immich route specifically — not globally — the same targeted-timeout principle that self-hosted AI apps need. ## Edge case 4: Immich thinks it's on the wrong URL Immich builds share links and redirects from the host it believes it's serving. Behind a proxy that means forwarding the real host and scheme: ```nginx proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; ``` Miss `X-Forwarded-Proto` and Immich generates `http://` links on an HTTPS site — mixed-content failures and broken shares. ## Edge case 5: TLS and certificate renewal Immich over HTTPS is table stakes, but a cert that silently fails to renew breaks the mobile app days later with a confusing error. Make sure renewal actually reloads the proxy — the exact trap in [Let's Encrypt renew-and-reload behind a Docker proxy](https://deploytovps.com/guide/lets-encrypt-renew-reload-docker-proxy). ## The full Immich proxy checklist - [ ] `client_max_body_size` raised well above your largest video. - [ ] WebSocket upgrade headers forwarded. - [ ] Read/send timeouts raised on the Immich route only. - [ ] `Host` + `X-Forwarded-Proto` forwarded so links use the right URL. - [ ] HTTPS with renewal that reloads the proxy. ## Skip the hand-wiring Every edge case above is a setting that a managed deployment can set for you. Bringing Immich up from a template wires the proxy, TLS, and headers together instead of leaving you to rediscover `client_max_body_size` at 2am. ![ServerCompass selecting the Immich template before deploying](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Deploying Immich with its reverse proxy and TLS configured together in [ServerCompass](https://servercompass.app) — the edge cases above are handled before the first upload.* Get these five settings right and Immich behind a proxy is rock solid. Get any one wrong and you'll chase a different confusing symptom for each. --- ## An Easy Immich Install Path for People Who Don't Live in a Terminal Source: https://deploytovps.com/blog/guide/immich-easy-install-path-for-nontechnical-users Published: 2026-06-22 Tags: immich, beginners, self-hosted, photos, docker Immich's power scares off the people who'd benefit most. A gentler install path that skips the hand-edited Compose file and still lands a real, updatable, backed-up instance. Immich is the self-hosted Google Photos replacement people actually want — and the install instructions are where most of them quietly give up. The official path assumes you'll download a `docker-compose.yml` and a `.env`, edit both, understand volumes and ports, and put a reverse proxy in front. For someone who just wants their photos off the cloud, that's a wall. Here's a gentler path that still ends in a *real* Immich instance — one that updates, has TLS, and is backed up — not a fragile toy. ## Why the default path bottlenecks The Compose approach isn't wrong; it's just front-loaded with concepts. To get to "I can see my photos," a newcomer has to absorb containers, environment files, volume mounts, port mapping, and reverse proxies — all before any payoff. Each is a place to typo a path or expose the wrong port. The result is a half-finished install on `http://192.168.1.x:2283` that never gets TLS or backups. ## What "a real install" actually requires Whatever path you take, a grown-up Immich instance needs five things. Keep this as the bar: 1. **The app + its database + ML service**, brought up together at compatible versions. 2. **Persistent storage** for originals that won't vanish on a restart. 3. **HTTPS** so the mobile app will connect reliably. 4. **Updates** you can apply without breaking the database (Immich runs irreversible migrations — only ever move forward). 5. **Backups** of the database *and* the library, with a restore you've tried once. ## The gentle path: deploy from a template The biggest simplification is to stop hand-assembling the stack. A managed template brings up the app, database, and storage as a unit, with TLS handled, so the newcomer's job is "pick Immich and click deploy," not "edit two YAML files correctly." ![ServerCompass app-template picker with Immich selected from the catalog](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Choosing Immich from the template catalog in [ServerCompass](https://servercompass.app) — no Compose file to edit; the database, storage, and reverse proxy are provisioned for you.* Once it's deployed, the dashboard view is where a non-technical owner can actually operate it — see that it's running, open it, restart it, check backups — without a terminal: ![ServerCompass dashboard showing Immich deployed and running](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *A deployed Immich in the [ServerCompass](https://servercompass.app) dashboard — running status, a real URL, and restart/backup controls in the UI.* ## Don't skip the two things beginners always skip - **HTTPS, from the start.** The Immich mobile apps are happiest over a real certificate. A template that wires TLS for you removes the single most common "the app won't connect" problem. - **A backup before you add the family's photos.** The day before you import 40,000 irreplaceable photos is the day to confirm the backup runs and restores. The principle is the same as any [volume restore drill](https://deploytovps.com/guide/docker-volume-restore-drill) — do it once, on purpose. ## If you do want to learn the Compose way later Nothing here stops you graduating to hand-managed Compose once you're comfortable — and when you do, the [Immich-behind-a-reverse-proxy edge cases](https://deploytovps.com/guide/immich-behind-reverse-proxy-edge-cases) are the next thing worth reading. But "learn Docker thoroughly" shouldn't be the toll gate between a person and their own photo library. ## Takeaway Immich earns its hype, and its install instructions cost it users who'd love it. Lower the bar with a template deploy that handles the database, storage, and TLS, insist on HTTPS and a tested backup, and only ever update forward. That's a real instance a non-technical person can actually keep running. --- ## OS End-of-Life Migrations Expose How Fragile a Multi-App VPS Really Is Source: https://deploytovps.com/blog/guide/os-eol-migration-multi-app-vps Published: 2026-06-22 Tags: migration, os-upgrade, multi-app, vps, reliability A single-box VPS running eight apps is fine until the OS hits end of life and every app must move at once. How to make the OS-EOL migration boring instead of terrifying. For years the single-VPS-runs-everything approach feels efficient: one box, eight apps, one bill. Then the OS hits end of life — no more security updates — and you discover the bill was deferred, not avoided. Now every app on the box has to move or be revalidated *at the same time*, because they all share one operating system. An OS-EOL migration is the moment a crowded multi-app VPS shows you exactly how coupled it really was. ## Why one OS makes everything move together On a multi-app box, the apps share a kernel, a libc, a Docker version, and a set of system packages. When the OS goes EOL you can't upgrade it "just for one app" — the whole machine moves, and any app that depended on an old system library, a specific Docker behavior, or a now-removed package comes along whether it's ready or not. That shared fate is invisible right up until this migration. ## Two strategies: upgrade in place vs rebuild and reattach **In-place upgrade** (`do-release-upgrade` and friends): tempting because it's one command, risky because it mutates a working machine. If it goes sideways mid-upgrade you can be left with a half-migrated box and eight broken apps. Only reasonable if you have a full snapshot to roll back to and a maintenance window. **Rebuild and reattach** (usually the better choice): stand up a *fresh* VPS on the new OS, deploy the apps cleanly, and reattach the data. Because your apps are containers with data in volumes, "move the app" is really "move the volume and redeploy the container." This is the same muscle as a [single-VPS-to-multi-host migration ladder](https://deploytovps.com/guide/single-vps-to-multi-host-migration-ladder) — and it doubles as a chance to spread the apps out instead of recreating the same crowded box. ## Make the data portable first The migration is only as safe as your ability to move state: - **Inventory the volumes and databases** — every app's data path and every database, written down. - **Dump databases logically**, don't just copy files (the [Immich/Nextcloud data-migration rules](https://deploytovps.com/guide/migrate-immich-nextcloud-data-without-breaking-app-state) apply to any database-backed app). - **Rehearse one restore** on the new box before cutting over everything. ## Avoid recreating the crowding EOL migration is the rare forced opportunity to fix the underlying fragility. Instead of rebuilding one box with eight apps, consider splitting the critical ones onto their own VPS so the *next* OS-EOL only moves part of your stack at a time. While you're at it, resolve the [port collisions](https://deploytovps.com/guide/multi-app-vps-port-collisions) that a single crowded box accumulates. ## Cut over deliberately - Bring the new box up with apps deployed and data restored, but DNS still pointing at the old one. - Verify each app on the new box directly (by IP/host header) before flipping DNS. - Flip DNS, watch, and keep the old box around — powered off but intact — until you're sure. - A real benchmark of the new box ([performance, not the spec sheet](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs)) confirms it can carry the load before you commit. ## Checklist - [ ] Full snapshot of the old box before touching anything. - [ ] Volume + database inventory written down. - [ ] Rebuild-and-reattach preferred over in-place where feasible. - [ ] One restore rehearsed on the new OS. - [ ] Critical apps split out to reduce the next migration's blast radius. - [ ] DNS cutover only after per-app verification; old box kept as rollback. ## Takeaway OS end-of-life turns a comfortable single box into a forced, all-at-once migration. Rebuild on the new OS and reattach your data rather than mutating a working machine, make the data portable before you start, and use the moment to un-crowd the box so the next EOL is smaller. Handled that way, an OS-EOL migration is a planned afternoon, not an emergency. --- ## Self-Hosted AI Turns Capacity Planning Into a Deployment Problem Source: https://deploytovps.com/blog/guide/self-host-ai-capacity-planning-host-sizing Published: 2026-06-22 Tags: self-hosted-ai, capacity-planning, ollama, gpu, hardware Adding a local LLM to your homelab isn't installing an app — it's a capacity decision. How to size the host (RAM, VRAM, disk, thermals) before the model decides for you. Most self-hosted apps are a rounding error on a modern box. A local LLM is not. The moment you add Ollama or an inference server to a homelab, you've changed the machine's capacity math — and if you treat it like installing any other container, the model quietly takes the resources your other apps needed. Self-hosted AI is less an install and more a capacity decision you should make on purpose. (Once the host is sized, the per-service knobs — memory limits, timeouts — are covered in [sizing self-hosted AI and search apps](https://deploytovps.com/guide/self-host-ai-search-resource-sizing-timeouts); this is the layer above that: the box itself.) ## VRAM is the real currency for GPU inference If you want responsive inference, you want a GPU, and the constraint is **VRAM**, not system RAM. The model's quantized weights must fit in VRAM (with room for context): - ~8 GB VRAM comfortably runs 7B-class models quantized. - ~12–16 GB opens up 13B-class. - 24 GB+ is where larger models and longer context become practical. Spilling to system RAM works but collapses throughput. Size the GPU for the largest model you actually intend to run, not the smallest one you'll start with. ## CPU inference is viable — if you set expectations No GPU? CPU inference runs, but it's seconds-to-tokens, not tokens-per-second. It's fine for occasional, non-interactive use (a nightly summarization job) and frustrating for a chat UI. Budget plenty of system RAM (the whole model sits in it) and accept the latency, or move the workload to a box that has a GPU. ## Disk: models are quiet space hogs Each model is multiple gigabytes, and it's easy to accumulate a dozen "just to try." Put the model cache on its own volume with room to grow, and prune unused models — the same disk-headroom discipline every stateful service needs. Reserve 20–30% free or the next pull fails mid-download. ## Thermals and power are part of capacity Sustained inference pins a GPU or CPU at load for minutes. In a mini PC or a closet rack that means heat and power draw your other workloads now compete with. Check that the box can run the model *continuously* without thermal throttling — a quick burst benchmark hides the steady-state problem. A [real performance benchmark rather than the spec sheet](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs) is the honest way to know what the hardware sustains. ## The real decision: shared box or dedicated? This is the planning call self-hosted AI forces: - **Shared box** — fine if the model is small, usage is occasional, and you've capped its resources so it can't starve your databases and media apps. - **Dedicated box** — the right answer once inference is interactive, frequent, or GPU-bound. Isolating it protects everything else and lets you size the AI machine for AI without compromising the rest. If you're consolidating other services to make room, watch that you don't just recreate [database sprawl](https://deploytovps.com/guide/homelab-database-sprawl-consolidation) on the remaining box. ## Capacity checklist before you deploy - [ ] Target model size chosen; VRAM (or RAM for CPU) sized to fit it + context. - [ ] Inference path decided: GPU for interactive, CPU only for batch. - [ ] Model cache on its own volume with 20–30% headroom. - [ ] Sustained-load thermals/power verified, not just a burst test. - [ ] Shared-vs-dedicated decision made deliberately, with resource caps if shared. Plan the capacity first and self-hosted AI slots cleanly into a homelab. Skip it and the model makes the capacity decision for you — usually at the expense of everything else on the box. --- ## Self-Hosted Runbooks Fail When One Person Owns the Config Behind the 2am Page Source: https://deploytovps.com/blog/guide/self-host-runbooks-bus-factor-oncall Published: 2026-06-22 Tags: runbooks, on-call, reliability, documentation, self-hosted Your homelab's bus factor is one, and that one is asleep at 2am. How to write runbooks and share access so a service outage isn't a single-person emergency. Every self-hoster eventually hits the same wall, and it has nothing to do with technology. The services are humming, the family relies on them — and *all* of it lives in one person's head and one person's SSH keys. When that person is asleep, traveling, or just tired of being the 24/7 on-call for their own house, a five-minute fix becomes a multi-hour outage because nobody else can even get in. Bus factor of one is the real reliability problem in most homelabs. ## The symptom: a personal emergency, not an incident A healthy outage looks like: an alert fires, someone follows a runbook, the service comes back. An unhealthy one looks like: the dashboard's down, the only person who knows the reverse-proxy quirk is unreachable, and the recovery is reconstructed from memory under pressure. The difference isn't skill — it's whether the knowledge and the access were ever externalized. ## Write runbooks for your future self at 2am A runbook isn't documentation of how the system works; it's a checklist for when it doesn't. Keep them short, specific, and findable: - **One page per common failure**, named by symptom: "Photos app won't load," "Site shows 502," "Backups stopped." - **The exact commands**, not "restart the service" — the literal `docker compose -f /opt/immich/compose.yml restart` with the real path. - **Where things live**: which box, which directory, which `.env`, which dashboard. - **What 'fixed' looks like**: the check that confirms recovery, so you're not guessing. A 502 runbook that points straight at [the stale-upstream cause of intermittent 502s](https://deploytovps.com/guide/intermittent-502-stale-proxy-upstream) is worth more at 2am than a perfect architecture diagram. ## Share access *before* you need it Runbooks are useless if the second person can't get in. Without leaking your whole identity: - Give a trusted second person their **own** SSH key on the critical boxes (their key, not your password). - Store secrets in a shared vault the household can reach, not in your head — and keep the *management* plane reachable only over your private overlay, per [safer Docker management boundaries](https://deploytovps.com/guide/docker-socket-safe-management-boundaries). - Make sure at least the "turn it off and on again safely" actions can be done from a UI by someone who isn't a sysadmin. ## Route alerts so the right person is paged — calmly If every blip pages you personally on every channel, you'll mute it, and then the real outage is silent. Route alerts by severity to the right place, with enough context to act: ![ServerCompass alert routing to Discord and Telegram with severity templates](https://assets.stoicsoft.com/posts/servercompass/alert-routing-discord-telegram-severity-templates.png) *Severity-based alert routing in [ServerCompass](https://servercompass.app) — a disk-nearly-full warning and a service-down page go to different channels, so the 2am notification is one that actually deserves the wake-up.* Low-noise alerting is its own discipline; the [low-noise uptime checks for a homelab](https://deployhandbook.com/best/low-noise-uptime-checks-homelab) approach keeps the signal worth responding to. ## A bus-factor checklist - [ ] A symptom-named runbook for each common failure, with literal commands and paths. - [ ] A second person with their own credentials on critical boxes. - [ ] Secrets in a shared vault, not one person's memory. - [ ] Basic recover actions doable from a UI. - [ ] Alerts routed by severity, with context, to a channel that isn't muted. ## Takeaway The most fragile part of a homelab is usually the single human who runs it. Write the 2am runbooks, give a second trusted person real access, and route alerts so the wake-up is rare and actionable. Reliability you can't share isn't reliability — it's a hobby that pages you. --- ## WebSocket-Specific Checks to Run Before Rolling a Realtime App Deploy Source: https://deploytovps.com/blog/guide/websocket-checks-before-rolling-realtime-deploys Published: 2026-06-22 Tags: websockets, realtime, deployments, reverse-proxy, health-checks Realtime apps pass an HTTP health check and still drop every live connection on deploy. The WebSocket-specific checks that catch it before your users do. A realtime app — chat, collaborative editor, live dashboard, anything with a persistent connection — has a deploy failure mode that a normal HTTP health check completely misses. The new container answers `GET /health` with a 200, the rollout proceeds, and meanwhile every live WebSocket has been dropped, sticky routing is sending users to the wrong instance, or an idle-timeout is silently killing connections sixty seconds in. The app is "up" and the experience is broken. These are the WebSocket-specific checks to run *before* you roll. ## Check 1: the proxy actually upgrades The first thing to verify on any new edge config is that the WebSocket handshake completes end to end, not just that the HTTP page loads. The upgrade needs HTTP/1.1 and the `Upgrade`/`Connection` headers forwarded all the way through: ```nginx proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Test it directly (`websocat wss://app.example.com/ws`) rather than trusting that the page rendered. A frozen UI with a green health check is the signature of a missing upgrade. ## Check 2: idle timeouts longer than your heartbeat Proxies close "idle" connections, but a WebSocket can be legitimately quiet between messages. If `proxy_read_timeout` is shorter than your app's ping interval, connections drop on a timer. Set the proxy idle timeout comfortably above the app's heartbeat, and make sure the app *sends* heartbeats. This is the same targeted-timeout discipline reverse proxies need elsewhere — the related trap of a [stale upstream causing intermittent 502s](https://deploytovps.com/guide/intermittent-502-stale-proxy-upstream) shows up here as random disconnects. ## Check 3: sticky sessions if state is per-instance If your realtime app keeps connection state in memory (rooms, presence) and you run more than one instance, a load balancer that round-robins WebSocket frames will scatter a user's messages across instances. Either: - enable **sticky sessions** so a connection pins to one instance, or - move shared state to Redis/a pub-sub backplane so any instance can serve any connection. Pick one *before* you scale past a single instance — discovering it during a deploy is the worst time. ## Check 4: graceful connection draining on rollout A rolling deploy that kills the old container immediately severs every open socket at once. Clients reconnect in a thundering herd and the new instance gets slammed. Drain instead: - Stop accepting *new* connections on the old instance, let existing ones finish or migrate, then retire it. - Make sure the app handles `SIGTERM` by closing sockets cleanly with a reconnect signal, not by hard-exiting. ## Check 5: a rollback that actually restores connections If the deploy goes wrong, rolling back the container isn't enough if the proxy config also changed. Keep the rollback atomic and rehearsed — a [rollback playbook with alert context in one place](https://deploytovps.com/guide/rollback-plus-alert-context-same-place) is what turns "everyone got disconnected" into a 60-second recovery. ## Preflight checklist - [ ] WebSocket upgrade verified end to end (not just the HTTP page). - [ ] Proxy idle timeout > app heartbeat interval; app sends heartbeats. - [ ] Sticky sessions or a shared backplane decided before multi-instance. - [ ] Graceful drain on rollout; clean `SIGTERM` handling. - [ ] Atomic, rehearsed rollback covering proxy config too. HTTP health checks tell you the process started. For realtime apps, these five tell you the actual product still works. --- ## Moving Immich and Nextcloud Data Without Corrupting App-Owned State Source: https://deploytovps.com/blog/guide/migrate-immich-nextcloud-data-without-breaking-app-state Published: 2026-06-22 Tags: immich, nextcloud, migration, backups, data-integrity Copying an app's files by hand is how migrations break. Immich and Nextcloud own a database and an index alongside the files — move all of it together, or not at all. The most common way a self-hosted migration goes wrong: someone `rsync`s the photos or files directory to the new server, points the app at it, and waits for everything to show up. With Immich and Nextcloud, it doesn't — because for those apps the files on disk are only *part* of the truth. ## The files are not the whole app Immich and Nextcloud both maintain **app-owned state** that has to stay consistent with the files: - **Immich** keeps a PostgreSQL database (albums, faces, metadata, machine-learning results) plus generated thumbnails. The originals on disk mean nothing without the matching database row. - **Nextcloud** keeps a database (file metadata, shares, app config) and an `oc_filecache` table that *is* the index. The web UI shows the cache, not a live `ls` of the directory. Copy the files alone and you get a new install that can't see them, duplicate entries, broken shares, or — worst case — a database that points at paths that no longer exist. The data isn't lost; the app just no longer believes the right things about it. ## Move everything, consistently, at one point in time The rule is: migrate the database, the files, and any index/cache **as one consistent snapshot**, with the app stopped. 1. **Stop the app** (and its workers) so nothing writes mid-copy. 2. **Dump the database** with the app quiesced (`pg_dump` for Immich's Postgres; `mysqldump`/`pg_dump` for Nextcloud). 3. **Copy the data directory** after the dump, so files and DB reflect the same moment. 4. **Restore both** on the new host, then let the app reconcile (Nextcloud: `occ files:scan`; Immich: let the jobs re-run). A consistent snapshot beats a clever live copy every time. If you've never rehearsed the restore side, do it once on a throwaway target first — a [volume restore drill](https://deploytovps.com/guide/docker-volume-restore-drill) turns "I think this backup works" into "I've watched it come back." ## Prefer app-native migration paths Both apps give you tools that respect their own state — use them instead of going around them: - **Nextcloud:** put the instance in maintenance mode, back up `config.php`, the data dir, and the database together; after restore run `occ maintenance:data-fingerprint` and `occ files:scan --all`. - **Immich:** back up the Postgres database and the upload/library folders together; on restore, the same library path plus the restored DB brings albums and faces back intact. Going *through* the app's tooling is what keeps the index and the files agreeing. ## Deploy the target cleanly first Migrations are smoother when the destination is a known-good install rather than a hand-assembled one. Bringing the app up from a template gives you the right database, volumes, and version before you pour data in: ![ServerCompass selecting the Immich app template from its catalog](https://assets.stoicsoft.com/template-guides/servercompass/immich/04-select-immich-template.png) *Standing up a clean Immich target in [ServerCompass](https://servercompass.app) — app, database, and storage provisioned together so the restore lands on matching infrastructure.* ## Don't forget the boring blockers - **Version skew:** restore into the *same or newer* app version, never older. Immich in particular runs DB migrations on upgrade that aren't reversible. - **Object/file permissions:** the app's user must own the restored data dir, or it silently can't read its own files. - **External access after the move:** if the new host is behind CGNAT or a new domain, sort access before you cut over — see [reaching self-hosted apps behind CGNAT](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443). ## Takeaway Immich and Nextcloud are database-plus-files applications wearing a folder. Treat the database, the files, and the index as one unit, move them together with the app stopped, restore into an equal-or-newer version, and let the app rebuild its caches. Bypass that and you're not migrating data — you're manufacturing a corruption incident. --- ## Splitting App Storage from Bulk Storage on a NAS-plus-Mini-PC Homelab Source: https://deploytovps.com/blog/guide/nas-mini-pc-app-storage-split-before-docker Published: 2026-06-22 Tags: nas, mini-pc, storage, docker, homelab The NAS-plus-mini-PC combo only stays fast and reliable if you decide early what lives on the SSD and what lives on the spinning pool. A clear split before Docker piles up. The NAS-plus-mini-PC homelab is a great pairing: the mini PC runs the containers, the NAS holds the terabytes. It goes wrong when the split between them is never actually decided, and Docker volumes land wherever the first tutorial put them. A month later there's a SQLite database living on an NFS share, a `docker-compose.yml` that assumes a path that only exists at 2am when the NAS is awake, and a Jellyfin library that's somehow both places. Decide the split before the stack grows and none of that happens. ## Two kinds of storage, two very different needs - **App state** — databases, app config, indexes, lock files. Small, latency-sensitive, and *intolerant of the disk disappearing*. Wants fast local SSD and a filesystem that never goes away mid-write. - **Bulk data** — media, photos, backups, archives. Large, throughput-oriented, fine over the network, tolerant of slightly higher latency. Almost every homelab storage problem comes from putting app state on bulk storage. NFS latency, a sleeping NAS disk, or a dropped mount turns into a corrupted database — exactly the failure mode covered in [USB-DAS storage guardrails](https://deploytovps.com/guide/usb-das-mini-pc-storage-guardrails-before-docker). ## The split: SSD for state, NAS for bulk - **Databases and app config → the mini PC's internal SSD/NVMe.** Postgres, MariaDB, SQLite, and each app's config directory live local. Fast, always-present, journaled. - **Media and large files → the NAS.** Mount the share read-write for the media apps that need it, and keep it out of the database path entirely. Concretely, for a media app: the *library files* live on the NAS share, but the app's *database and metadata* live on the SSD. That one separation is the difference between "the NAS hiccuped and Jellyfin paused" and "the NAS hiccuped and the database is corrupt." ## Make the mounts explicit and resilient - Mount NAS shares by a stable name with `nofail` so a missing NAS doesn't wedge boot. - Set `soft`/timeout options on NFS so an unreachable NAS errors instead of hanging containers forever. - In Compose, map the SSD path and the NAS path as distinct named volumes/binds — never a single tree that straddles both. ## Watch the cross-cutting effects Once several apps share one mini PC and one NAS, they compete. Keep app ports and resources from colliding (see [multi-app VPS port collisions](https://deploytovps.com/guide/multi-app-vps-port-collisions)), and back up the *SSD app state* off the box — the NAS holding your media is not a backup of the database that indexes it. ## Quick layout | Data | Lives on | Why | |---|---|---| | Postgres/MariaDB/SQLite | mini-PC SSD | latency + always-present | | App config / index | mini-PC SSD | small, must stay consistent | | Media / photos / archives | NAS | large, throughput, network-tolerant | | Backups of app state | off-box / NAS *and* off-site | survive loss of either box | ## Takeaway Decide it once: state on the SSD, bulk on the NAS, mounts explicit and `nofail`, app-state backups off the box. Make that call before the first stateful container and the NAS-plus-mini-PC homelab stays fast, boots cleanly, and survives the inevitable NAS nap. --- ## A Sane Proxmox Storage Plan: TrueNAS, LXC, NFS, and Backups Without Overlap Source: https://deploytovps.com/blog/guide/proxmox-storage-plan-truenas-lxc-nfs-backups Published: 2026-06-22 Tags: proxmox, truenas, storage, nfs, backups Proxmox storage gets risky when a TrueNAS VM, LXC bind mounts, NFS shares, and PBS backups all claim the same disks. A layout that keeps the layers from fighting. Proxmox storage gets dangerous at exactly the moment it gets interesting: you add a TrueNAS VM for your pool, bind some datasets into LXC containers over NFS, and point Proxmox Backup Server at "the NAS." Now four layers all have an opinion about the same physical disks, and the failure modes — a backup job that deadlocks against the VM serving its own target, a pool that won't import because two systems claimed it — are nasty precisely because they only show up under load. The fix is a storage *plan*, decided before you create the pool. ## Decide who owns the disks — exactly one thing The cardinal rule: **one system owns each physical disk, full stop.** The usual mistake is letting Proxmox's ZFS *and* a TrueNAS VM both think they manage the same drives. - If TrueNAS owns the pool, pass the **HBA/controller through** to the TrueNAS VM (PCIe passthrough), so Proxmox never touches those disks. TrueNAS sees real hardware; Proxmox sees none of it. - If Proxmox owns the pool, build ZFS on Proxmox and *don't* also virtualize TrueNAS on the same disks. Run TrueNAS only if it has its own dedicated controller and drives. Half-passthrough (handing TrueNAS virtual disks backed by a Proxmox ZFS pool) is the configuration that bites — you get ZFS-on-ZFS, double caching, and confusing performance. Avoid it. ## Map the layers top to bottom Write down the stack so the dependencies are visible: 1. **Physical disks** → owned by one system (TrueNAS-via-HBA, or Proxmox-ZFS). 2. **Datasets/pools** → named by purpose: `vm-store`, `media`, `app-data`, `backups`. 3. **Sharing** → NFS/SMB exported from the owner to consumers. 4. **Consumers** → VMs and LXC containers that mount the shares or use bind mounts. 5. **Backups** → Proxmox Backup Server with a target that is **not** served by a VM that PBS also backs up. That last point prevents the classic deadlock: don't make PBS write its backups to an NFS share exported by the very TrueNAS VM that PBS is trying to back up. Give backups an independent target — a separate disk, a separate box, or off-site. ## LXC bind mounts vs NFS For containers on the same host as the storage, **bind mounts** are simpler and faster than looping back through NFS. Reserve NFS for cross-host access. Mixing both for the same data ("bind mount here, NFS there") leads to permission drift and uid/gid mismatches between the host and the container. ## Backups are a separate failure domain Treat backups as their own layer with its own rules: - The backup target must survive the loss of the thing it backs up. Same pool ≠ a backup. - Snapshots are not backups — a `zfs destroy` or pool failure takes the snapshots too. - Rehearse a restore before you rely on it. A [restore drill](https://deploytovps.com/guide/docker-volume-restore-drill) applies just as much to a TrueNAS dataset as to a Docker volume. ## Plan checklist - [ ] Exactly one system owns each physical disk (HBA passthrough if TrueNAS). - [ ] No ZFS-on-ZFS; no half-virtualized pools. - [ ] Datasets named by purpose, not by app-of-the-week. - [ ] Bind mounts for same-host LXC; NFS only across hosts. - [ ] PBS target independent of the VM/host it protects. - [ ] A restore rehearsed at least once. When you're sizing the host itself for all of this — and deciding whether some workloads belong on a cheap cloud VPS instead — a [real benchmark, not the spec sheet](https://stoicvps.com/blog/vps-benchmark-checklist-real-performance-not-marketing-specs), tells you what the hardware can actually carry. Decide the storage plan first; the rest of the Proxmox build gets much calmer once the disks have one clear owner. --- ## When Self-Hosted Fleets Outgrow a Single Tunnel: Multi-Site Remote Access Source: https://deploytovps.com/blog/guide/remote-self-host-fleet-access-beyond-simple-tunnels Published: 2026-06-22 Tags: wireguard, tailscale, remote-access, networking, fleet One WireGuard tunnel is fine for one box. Across several sites and a dozen devices it turns into a mess of overlapping subnets and manual keys. Here's how to scale remote access. A single point-to-point tunnel is the right answer for one server and one laptop. The trouble starts when "one box" quietly becomes a fleet: a homelab at home, a mini PC at a relative's house, a couple of cloud VPSes, and a dozen client devices. Suddenly you're hand-managing keys, tripping over overlapping `192.168.1.0/24` subnets, and discovering that device A can reach the server but device B can't reach device A. The tunnel didn't fail — it just stopped being the right shape. ## Symptoms that you've outgrown a single tunnel - You maintain WireGuard peers by editing config files by hand on every node. - Two sites use the same LAN subnet, so routing to "the other 192.168.1.10" is ambiguous. - Access is hub-shaped when you need any-to-any (the [CGNAT relay pattern](https://deploytovps.com/guide/cgnat-clean-domain-access-without-exposing-443) gets your apps reachable, but it doesn't make every node talk to every other node). - Onboarding a new device means touching every existing node's config. ## Option 1: a mesh overlay (least ongoing effort) A mesh VPN like Tailscale gives every node a stable address on a private network and handles NAT traversal, key rotation, and ACLs centrally. Any node can reach any other node it's allowed to, regardless of which site it's at. For most growing fleets this is the pragmatic answer — the operational cost of hand-managed WireGuard is what you're really trying to escape. If you want the mesh ergonomics without a hosted control plane, **Headscale** is an open, self-hosted coordination server that speaks to the same clients — you run the brain yourself and keep the convenience. ## Option 2: hub-and-spoke WireGuard (most control) If you prefer plain WireGuard, scale it deliberately as hub-and-spoke: one or two well-connected hubs (a cloud VPS with a public IP) that every site dials into, with routes advertised so spokes can reach each other *through* the hub. The discipline that makes this survivable: - **Non-overlapping subnets** — give every site a distinct range (`10.10.1.0/24`, `10.10.2.0/24`, …). Overlapping LANs are the number-one multi-site headache. - **A single source of truth** for peers and `AllowedIPs`, generated rather than hand-edited. - **Keepalives** on NAT'd spokes so tunnels don't go quiet and drop. For Linux nodes specifically, watch the stability and DNS gotchas covered in [Tailscale on Linux: stability and backups](https://deploytovps.com/guide/tailscale-linux-stability-backup) — they apply to WireGuard too. ## Option 3: don't mesh what doesn't need to mesh Not every service needs fleet-wide reachability. Public-facing apps can sit behind a reverse proxy on a hub VPS and be reached over the internet with TLS, while only *management* traffic rides the overlay. Separating "things the public reaches" from "things only I reach" keeps the private network small and the attack surface smaller. Keep management off routable interfaces entirely — the boundary discipline in [safer Docker management boundaries](https://deploytovps.com/guide/docker-socket-safe-management-boundaries) is the same idea applied to the control plane. ## Choosing - **Several sites, many devices, want it to just work:** mesh overlay (Tailscale or self-hosted Headscale). - **You value full control and don't mind the bookkeeping:** hub-and-spoke WireGuard with disciplined subnets. - **Mostly public apps, a little private management:** reverse proxy for the public side, a small overlay for the rest. ## See the whole fleet in one place However you connect the fleet, you still need to *see* it — which node is down, which cert is expiring, which disk is full across sites. A single pane over hybrid infrastructure beats SSHing into each box to check. ![ServerCompass dashboard showing several servers across regions in one view](https://assets.stoicsoft.com/posts/servercompass/one-dashboard-hetzner-homelab-hybrid-infra.png) *One dashboard over a multi-site fleet in [ServerCompass](https://servercompass.app) — cloud VPSes and home boxes side by side instead of a folder of SSH bookmarks.* A single tunnel is a tool, not an architecture. Once you have a fleet, pick a model that scales onboarding and routing, keep subnets unique, and stop editing peer configs by hand. --- ## Sizing Self-Hosted AI and Search Apps: Limits and Timeouts Before You Deploy Source: https://deploytovps.com/blog/guide/self-host-ai-search-resource-sizing-timeouts Published: 2026-06-22 Tags: self-hosted-ai, resource-limits, timeouts, ollama, elasticsearch Self-hosted LLMs, vector stores, and search engines fall over from defaults, not bugs. Set memory limits, timeouts, and disk headroom before the first deploy. Self-hosted AI and search apps rarely fail because the software is broken. They fail because the defaults assume a bigger, quieter machine than the one you gave them. An LLM runner pulls a 7B model and the box starts swapping. A vector database indexes a corpus and the OOM killer reaps it mid-write. A search engine's heap balloons until the host's other apps stutter. The fix is boring and it happens *before* the first deploy: decide the limits and timeouts, then ship. ## Right-size RAM before you pick the model Memory is the constraint that bites first. A rough planning rule for a single-box homelab: - **LLM inference (Ollama, LocalAI):** budget for the model's quantized size plus headroom. A 7B Q4 model wants ~6 GB resident; a 13B wants ~10 GB. CPU-only inference is slow but survivable; GPU inference needs the VRAM, not just system RAM. - **Search/index (Elasticsearch, OpenSearch):** the JVM heap should be ~50% of the container's memory and never cross ~31 GB. Give the OS page cache the other half. - **Vector stores (Qdrant, Chroma, Weaviate):** index build is the spike, not steady state. Size for the build, not the idle footprint. If the total exceeds the host, you don't have a tuning problem — you have a placement problem. Move the heavy app to its own VPS rather than starving everything else. ## Set container memory limits so one app can't take the box Defaults let a container consume all host memory. On a shared box that means one runaway model evicts your database. Pin limits explicitly: ```yaml services: ollama: deploy: resources: limits: memory: 8g ``` A container that hits its own limit and restarts is a contained incident. A host that runs out of memory is an outage for *everything* on it. If you're juggling several apps on one VPS, pair limits with deliberate port and resource separation — see [multi-app VPS port collisions](https://deploytovps.com/guide/multi-app-vps-port-collisions). ## Timeouts: the failure mode nobody sets AI and search requests are slow and bursty, so the defaults are wrong in both directions. A reverse proxy with a 60s timeout will cut off a cold-start model load; a missing client timeout will let a stuck embedding call pin a worker forever. Set them on purpose: - **Reverse proxy** (`proxy_read_timeout` / Traefik `responseHeaderTimeout`): long enough for a cold model load, e.g. 120–300s on the AI route only — not globally. - **App/client timeouts:** bound every outbound call so a hung upstream frees the worker. - **Health checks:** generous `start_period` so a slow first load isn't flapped as "unhealthy." See [sane Docker health-check defaults](https://deploytovps.com/guide/docker-health-checks-sane-defaults-for-self-hosted-services). ## Reserve disk headroom for models and indexes Models and indexes grow quietly. An LLM cache fills with pulled models; a search index doubles during a reindex; logs from a chatty inference server eat the root volume. Keep 20–30% free on the data volume, put model/index data on a named volume you can grow, and alert on disk *before* it's full, not after. ## Deploy from a template so the sizing is visible Hand-wiring Compose for an LLM stack plus a vector DB plus a search engine is where the limits get forgotten. Deploying from a managed template makes the resource picture explicit up front — the template declares minimum RAM and brings the app, its datastore, and TLS up together. ![ServerCompass app management dashboard showing a deployed app's services and resource controls](https://assets.stoicsoft.com/template-guides/servercompass/immich/08-deployment-success.png) *Managing a deployed stack in [ServerCompass](https://servercompass.app) — services, restart, and resource controls in one place instead of scattered across Compose files.* ## Preflight checklist - [ ] Model/heap/index RAM budgeted and under host total (with headroom). - [ ] Per-container `memory` limits set on every AI/search service. - [ ] Proxy timeout raised **only** on the slow route, not globally. - [ ] Client/outbound timeouts bound everywhere. - [ ] Health-check `start_period` covers cold model load. - [ ] 20–30% free disk on the data volume + a disk alert. - [ ] Heavy app placed on its own VPS if the box can't hold the total. Size first, deploy second. Self-hosted AI is perfectly stable on modest hardware once it can't quietly eat the whole machine. --- ## Storage Guardrails for USB-DAS Mini-PC Servers Before Your First Docker Stack Source: https://deploytovps.com/blog/guide/usb-das-mini-pc-storage-guardrails-before-docker Published: 2026-06-22 Tags: mini-pc, storage, usb-das, docker, reliability A mini PC with a USB direct-attached drive is a great cheap server until a power blip or a sleeping disk corrupts a database. Set the storage guardrails before the first container. A mini PC plus a USB direct-attached storage (DAS) enclosure is one of the best value homelab setups there is — until the day a USB disk spins down mid-write, a UAS reset drops the volume, or a power blip leaves a database half-written. None of that is bad luck. It's the predictable result of treating a USB-attached disk like an internal one. Set a few guardrails before the first `docker compose up` and the setup is genuinely reliable. ## Understand what's different about USB-attached disks Internal SATA/NVMe disks stay present and powered. USB-DAS disks have three failure modes you must design around: - **Disk sleep / spin-down** — the enclosure or the OS parks the drive, and the next write stalls or errors. - **UAS resets** — under load, some USB-SATA bridges reset the link, and Linux re-enumerates the device, sometimes with a *different* device name. - **Power independence** — the enclosure can lose power while the mini PC stays up, so the OS sees a disk vanish mid-operation. Every guardrail below exists to neutralize one of those. ## Guardrail 1: stable mounts, never `/dev/sdX` Because USB devices can re-enumerate, never mount by `/dev/sda1`. Mount by **UUID** (or a filesystem label) in `/etc/fstab`, and add `nofail` so a missing enclosure doesn't block boot. A journaling filesystem (ext4/XFS) recovers from an unclean unmount far better than exFAT, which has no journal and is a poor choice for app data. ## Guardrail 2: stop the disks from sleeping A database doesn't tolerate its storage disappearing for a few seconds. Disable aggressive power management on the data disks (`hdparm -B 255 -S 0`, and disable USB autosuspend for the enclosure). If you must keep spin-down for archival drives, keep *application data* off them entirely. ## Guardrail 3: put databases where they belong This is the big one. SQLite and Postgres on a flaky USB volume is the fastest path to corruption. Two safe patterns: - Keep **databases and app config on the mini PC's internal NVMe/SSD**, and put only **bulk media** (photos, video, downloads) on the USB-DAS. - Or, if everything must live on the DAS, accept that you need solid backups and a tested restore — the corruption risk is real. Splitting fast app state from bulk storage is a theme worth its own plan; see [splitting app storage from bulk storage](https://deploytovps.com/guide/nas-mini-pc-app-storage-split-before-docker). ## Guardrail 4: survive power loss A small **UPS** that powers both the mini PC *and* the DAS enclosure removes the single nastiest failure — the enclosure browning out while the OS keeps writing. Even a cheap unit that buys 60 seconds for a clean shutdown is worth it. Pair it with `sync`-friendly settings and don't disable filesystem barriers to chase benchmark numbers. ## Guardrail 5: back up off the box A USB-DAS is not a backup — it's attached to the same machine and the same power. Send database dumps and irreplaceable media to a second location on a schedule, and rehearse the restore at least once. "I have backups" and "I have restored from backups" are different claims. ## Preflight checklist - [ ] Data volume mounted by UUID/label with `nofail`, on a journaling filesystem. - [ ] Disk sleep and USB autosuspend disabled for data disks. - [ ] Databases/app config on internal SSD; bulk media on the DAS. - [ ] UPS covering both the mini PC and the enclosure. - [ ] Off-box backups with a tested restore. Get these in place before you deploy anything stateful and the mini-PC-plus-DAS combo earns its reputation as the best cheap-but-serious homelab there is. --- ## Reaching Your Self-Hosted Apps Behind CGNAT — Without Exposing Port 443 Source: https://deploytovps.com/blog/guide/cgnat-clean-domain-access-without-exposing-443 Published: 2026-06-22 Tags: cgnat, jellyfin, immich, tailscale, reverse-proxy CGNAT removes inbound ports, not your options. A decision tree for clean domains and private access to Jellyfin and Immich using VPS relays, Cloudflare Tunnel, Tailscale, and WireGuard. You bought a domain, pointed it at your home server, opened the port — and nothing. Welcome to CGNAT. Your ISP has put you behind a shared, carrier-grade NAT, so there is no public inbound port to forward. Some ISPs will open a single *random* port on request, which is how people end up sharing `https://example.com:43117/` and hating it. The good news: you can get clean, secure access to Jellyfin, Immich, and the rest without a public IP and without exposing port 443 on your home connection. The trick is to stop trying to accept inbound connections at home, and instead reach out to something that *can* accept them. ## First, confirm you're actually behind CGNAT Check the WAN IP your router shows, then compare it to your public IP (search "what is my IP"). If they differ — and especially if your router's WAN address is in `100.64.0.0/10` — you're behind CGNAT. Port forwarding will never work from the outside, no matter how the router is configured. That single check saves hours of debugging firewall rules that were never the problem. ## The core idea: relay, don't expose Every workable option follows the same shape: a machine with a real public IP accepts connections, and your home server maintains an *outbound* tunnel to it. Inbound at home stays fully closed. The options differ in who runs that public machine and how private the traffic stays. ### Option 1 — Your own VPS as a reverse-proxy relay (most control) Rent a small VPS, connect it to your home server over **WireGuard**, and run a reverse proxy (Caddy, Traefik, or nginx) on the VPS. The proxy terminates TLS for `media.example.com` and forwards over the WireGuard tunnel to Jellyfin/Immich at home. - Clean domains, real Let's Encrypt certs, no ugly ports. - Nothing inbound is open at home; the VPS only talks to your origin through the encrypted tunnel. - You choose the VPS region (useful for latency and for keeping data in a jurisdiction you trust). - Trade-off: you operate the VPS and pay for its bandwidth — relevant for high-bitrate Jellyfin streaming. ### Option 2 — Cloudflare Tunnel (fastest to set up, with a caveat) `cloudflared` makes an outbound tunnel to Cloudflare's edge; you get a clean domain and TLS with almost no config and no VPS to manage. The caveat that keeps coming up in self-host threads: Cloudflare terminates TLS at *their* edge, so your traffic is decrypted there, and Cloudflare's terms restrict heavy media streaming through the free proxy. For dashboards, photos, and light use it's excellent. For a privacy-sensitive Immich library or heavy Jellyfin streaming, many people are uncomfortable routing plaintext media through a third party — which points back to Option 1 or 3. ### Option 3 — Tailscale (and Serve/Funnel) for private and family access If access is mostly for *you and a few people*, a WireGuard-based mesh like **Tailscale** is often the cleanest answer: install it on the server and on each client, and the apps are reachable over the private tailnet with no public exposure at all. - **Tailscale Serve** gives those tailnet clients HTTPS with a real cert on a `*.ts.net` name — no public ingress. - **Tailscale Funnel** can expose a service publicly through Tailscale's edge when you genuinely need a non-tailnet visitor to reach it. - For a fully self-hosted control plane, **Headscale** replaces Tailscale's coordination server while keeping the same clients — the move several Synology users make to get off the hosted control plane without exposing the NAS. A recurring gotcha: a Jellyfin/Docker setup works on one tailnet but breaks when *shared across another tailnet*. Cross-tailnet sharing has its own ACL and DNS rules — if a shared user can't connect, check the share's ACLs and that MagicDNS resolves the target on their side before assuming the container is misconfigured. ### Option 4 — Keep it VPN-only (most private) If nobody outside your household needs access, the simplest secure answer is to expose nothing. Run WireGuard (or Tailscale) and require clients to be on the VPN to reach any service. No public domain, no edge, no third party in the path. The cost is that every device must run a VPN client — fine for you, sometimes a hard sell for non-technical family members. ## A decision tree - **Want a clean public domain and full control over the path?** VPS relay + WireGuard + reverse proxy (Option 1). - **Want it working in 15 minutes for dashboards/light use?** Cloudflare Tunnel (Option 2) — accept edge TLS termination. - **Access is just you and a few trusted people?** Tailscale/Headscale (Option 3); use Serve for HTTPS, Funnel only when you must go public. - **Maximum privacy, no public surface at all?** VPN-only (Option 4). ## Protect the private media traffic The whole reason CGNAT workarounds get debated so heavily is that media is sensitive. Two rules keep it clean: 1. **Don't let an unencrypted hop touch your library.** If you use an external edge that terminates TLS, assume that operator can see the content. For private photos and home video, prefer a path where TLS terminates on hardware you control (your VPS or your own device) — Options 1, 3, and 4 all qualify. 2. **Separate "clean URL" from "who can reach it."** A nice domain is about ergonomics; access control is a separate decision. Even with a public domain, gate sensitive apps behind auth (an auth proxy, or the app's own SSO) rather than relying on obscurity. ## Recommended setups for common cases - **Immich for the family, privacy-first:** VPS relay + WireGuard + Caddy, or Tailscale with Serve. Avoid plaintext third-party edges. - **Jellyfin streaming to a few people:** VPS relay (watch bandwidth/region) or Tailscale; skip Cloudflare's proxy for heavy streams. - **A dashboard or two, low stakes:** Cloudflare Tunnel is the least-effort clean-domain option. - **Just you, all devices you control:** VPN-only is the smallest attack surface that exists. ## The takeaway CGNAT removes inbound ports, not your options. Stop trying to accept connections at home and instead maintain an outbound tunnel to something that can — a VPS you control, a tunnel provider, or a WireGuard mesh. Pick based on who needs access and how private the traffic must be, terminate TLS somewhere you trust, and you'll get clean `https://media.example.com` URLs with port 443 at home staying firmly shut. --- ## Stop Exposing the Docker Socket: Safer Management Boundaries for a Homelab Source: https://deploytovps.com/blog/guide/docker-socket-safe-management-boundaries Published: 2026-06-22 Tags: docker, networking, security, homelab, remote-management Publishing the Docker socket to your LAN is handing out root on the whole box. Here are safer boundaries: SSH contexts, a scoped socket proxy, and real network segmentation. Exposing the Docker socket feels like the obvious shortcut. You want Portainer on another box, a metrics agent, or a CI runner to manage containers, so you publish `/var/run/docker.sock` over TCP — or bind it to `0.0.0.0:2375` "just on the LAN." It works immediately, which is exactly why it's dangerous. The socket is not a management API with permissions. It is root on the entire host. Anyone who can reach it can start a privileged container, mount `/` from the host, and read or rewrite anything on the machine. On a flat home network shared with a TV, a partner's laptop, and a handful of IoT devices, "just on the LAN" is a much larger blast radius than it sounds. This guide is about drawing safer boundaries: keeping the management plane away from your apps, scoping remote access, and segmenting the network so one mistake does not own the whole box. ## Why the Docker socket is special A normal service you expose — say, Jellyfin on 8096 — can only do what that service is allowed to do. The Docker socket is different. Talking to it is equivalent to running `docker` as root. With one API call an attacker can: - launch a container with `--privileged` and `-v /:/host` - read your `.env` files, TLS keys, and database volumes - install a persistent backdoor that survives container restarts There is no "read-only" mode in the raw socket. So the goal is never "secure the exposed socket." The goal is **don't expose the raw socket at all**, and give each real need a narrower tool. ## Separate the management plane from the apps The single most useful mental model: your **apps** (Jellyfin, Immich, Nextcloud, the *arr stack) and your **management plane** (Docker control, dashboards, metrics, deploy automation) are two different trust zones. They should not share an open door. - Apps get exposed deliberately, one port or one reverse-proxy route at a time. - Management never listens on a routable interface. You reach it by first getting *onto* the host or the private network, then talking to a local-only endpoint. In practice that means the socket stays as the default Unix socket (`/var/run/docker.sock`), reachable only by root and the `docker` group on that machine — never a TCP listener. ## The three things people actually want — and safer ways to get them Most "expose the socket" requests are really one of these: **1. Manage containers from another machine.** Don't publish the socket. SSH into the host and run `docker` there, or use Docker contexts over SSH: ```bash docker context create homelab --docker "host=ssh://you@homelab" docker --context homelab ps ``` This rides your existing SSH auth, is encrypted end to end, and exposes nothing new on the network. **2. Run a dashboard like Portainer.** Mount the socket into the dashboard container *on the same host* (`-v /var/run/docker.sock:/var/run/docker.sock`) and reach the dashboard's web UI over a private path — a reverse proxy behind auth, or a VPN — not a LAN-wide port. The socket never leaves the box; only the authenticated UI is reachable, and only privately. **3. Let automation or a metrics agent talk to Docker.** This is the one case where a TCP-ish path is tempting. Use a **socket proxy** (for example, a hardened `docker-socket-proxy` container) that sits in front of the real socket and allows only the specific API calls the client needs — typically read-only endpoints like `/containers/json` and `/events`, with `POST` disabled. Put it on an internal Docker network shared only with the agent, never published to the host's LAN interface. ## Encrypt and scope remote access If you genuinely need cross-machine management traffic, it should travel inside something private, not across the LAN in cleartext: - **SSH** for interactive control and Docker contexts. - **WireGuard or Tailscale** for a private overlay between hosts, so "remote" management still never touches a public or shared-LAN interface. If you ever do run a TCP Docker endpoint between servers, it must be mTLS (`tlsverify` with client certs) *and* confined to the WireGuard interface. But for a homelab, SSH contexts and a socket proxy cover almost every case without that complexity. ## Segment the network so a mistake stays contained The Reddit threads behind this pattern share a second failure mode: everything lives on one flat home network, so problems leak sideways. A common example — an Ubuntu Docker host running the *arr stack with Gluetun and qBittorrent — starts knocking Wi‑Fi cameras offline when plugged into the ISP router. That is not a Docker bug; it is a network-boundary bug. Practical segmentation, roughly in order of effort: - **Isolate VPN/torrent traffic.** Keep qBittorrent bound to the Gluetun container's network namespace so it can *only* egress through the VPN. If the tunnel drops, the traffic stops instead of falling back to your ISP link. - **Put management on its own VLAN or interface.** Dashboards, the host SSH port, and any metrics endpoints live on a management VLAN your IoT and guest devices cannot reach. - **Give IoT its own VLAN.** Cameras and smart plugs do not need to see your homelab, and your homelab does not need to see them. Even without managed switches, a second NIC or a dedicated bridge for management gets you most of the isolation benefit. ## Watch the DHCP and DNS side effects Container networking quietly changes layer-2 and layer-3 behavior. Macvlan interfaces request their own DHCP leases; a misconfigured Pi-hole or AdGuard container can become the network's DNS authority; bridge subnets can collide with your LAN range and silently break routing. Two habits prevent most of the "everything went weird after I added a container" incidents: - Pin container subnets to ranges that don't overlap your LAN/DHCP scope. - Decide deliberately whether a container should run its own DHCP/DNS, and if so, make sure exactly one device owns each role. ## Monitor the management plane separately If the box that controls all your containers goes sideways, you want to know before the apps do. Keep at least a minimal, independent check on the management plane — host reachability, the Docker daemon, and disk on the volume that holds your container data — separate from your app uptime checks. When something breaks, that separation tells you immediately whether you're looking at an app problem or a control-plane problem. ## A safe-defaults checklist - [ ] Docker socket stays a local Unix socket — no `tcp://` listener, no `0.0.0.0` bind. - [ ] Remote management via SSH / Docker contexts over SSH, or over WireGuard/Tailscale. - [ ] Dashboards mount the socket locally and are reachable only behind auth + a private path. - [ ] Automation talks to a **socket proxy** with a strict, mostly read-only allowlist. - [ ] VPN/torrent traffic is pinned to the VPN namespace with a kill-switch. - [ ] Management, apps, and IoT are on separate VLANs or interfaces. - [ ] Container subnets don't overlap your LAN; DHCP/DNS ownership is explicit. - [ ] The management plane has its own independent health check. ## The takeaway The Docker socket is the most powerful credential on your server, and exposing it to the LAN trades a few minutes of convenience for total-compromise risk. You almost never need to expose it — SSH contexts, a locally-mounted dashboard, and a tightly-scoped socket proxy cover the real use cases. Pair that with basic network segmentation and explicit DHCP/DNS ownership, and a single misstep stays contained instead of taking the whole house offline. --- ## Template Deployments Still Need Logs, Health Checks, and Redeploy Confidence Source: https://deploytovps.com/blog/guide/template-deployments-monitoring-logs-checklist Published: 2026-06-05 Tags: templates, monitoring, logs, redeploy, self-hosted, health-checks One-click templates make self-hosting faster, but they do not remove the need for logs, verification, rollback, backups, and redeploy drills. Template-based deployment tools are good for self-hosters. They turn "install this app" from a half-day of docs into a few fields and a deploy button. That is real progress. The trap is assuming that a successful template deploy means the service is production-ready. A template can start containers, create environment variables, attach a domain, and request a certificate. It cannot know whether your backup path is tested, whether the health check covers the important feature, whether logs are searchable after the container restarts, or whether the next redeploy will preserve the data you care about. That gap is where many self-hosted apps fail. The first install works. The second deploy, the first incident, or the first migration is where confidence disappears. This checklist is for the moment after the template says "deployed". ## 1. Confirm what the template created Before treating the app as live, inventory the resources the template created. Do not rely on the dashboard label alone. Check: - containers and their names - networks - volumes - exposed ports - environment variables - reverse proxy route - certificate coverage - database service, if any - backup path, if any On a Docker Compose based setup, start with: ```sh docker compose ps docker compose config docker volume ls docker network ls ``` The goal is not to memorize every generated line. The goal is to know which pieces exist and which ones you are now responsible for. If the template creates a named volume, that volume is probably where your data lives. If it creates a database container, that database needs a backup. If it exposes a port only through the reverse proxy, your health check should hit the public route and the internal service separately. ## 2. Make logs useful before you need them The default logs panel in a deployment tool is enough for "why did the container fail to start?" It is usually not enough for "what changed yesterday around checkout failures?" At minimum, answer these questions: - Where do stdout and stderr go? - How long are logs retained? - Can you search by time range? - Can you filter by service? - Do application logs include request IDs? - Are deploy events visible next to runtime errors? For small stacks, you do not need a full observability platform on day one. You do need a basic log routine: ```sh docker compose logs --since=30m app docker compose logs --tail=200 worker docker compose logs --since=24h proxy ``` If those commands are your only log viewer, write them into the runbook. If the app matters, add a lightweight log store such as Loki or a managed log drain. The key is to make the path explicit before the incident. ## 3. Add a real health check A green container is not the same as a healthy app. A template often tells you the process is running. Your users care whether the app can do the job. Use three layers: 1. **Process health.** Is the container running? 2. **HTTP health.** Does the public route return a valid response? 3. **Workflow health.** Does a small critical path work? For example: ```sh docker inspect --format='{{.State.Health.Status}}' my-app curl -fsS https://example.com/health curl -fsS https://example.com/api/status ``` The workflow health check depends on the app. For a CMS, it might verify the admin login page and public homepage. For a queue-backed app, it might submit a harmless job and confirm it is processed. For an analytics app, it might confirm writes reach the database. Do not make the workflow check destructive. Do make it specific enough to catch the failure your homepage will miss. ## 4. Tie verification to redeploys The riskiest moment is not the first deploy. It is the next deploy. Templates make redeploys feel safe because they hide the steps. That is exactly why you need a verification ritual: 1. Record the current image or commit. 2. Trigger the redeploy. 3. Wait for the new container to become healthy. 4. Hit the public route. 5. Run the workflow check. 6. Check the last 100 lines of app logs. 7. Confirm the rollback path still exists. This can be manual at first. A simple shell script is enough: ```sh set -e curl -fsS https://example.com/health curl -fsS https://example.com/api/status docker compose logs --tail=100 app ``` The important distinction is between "deploy completed" and "deploy verified". Treat them as separate states. ## 5. Prove the rollback path Rollback is not a button. Rollback is a tested path back to a known-good state. Before relying on a template in production, know: - whether previous images are retained - how to redeploy a previous version - whether database migrations are reversible - whether volumes are touched during deploy - whether environment variable changes are versioned - whether the reverse proxy route changes during rollback If the app has database migrations, be conservative. A code rollback cannot always undo a schema change. For small self-hosted apps, the practical pattern is: - snapshot the database before risky upgrades - snapshot or back up named volumes - keep the previous image tag - run a post-rollback health check That is less glamorous than a one-click rollback, but it is the difference between a dashboard feature and an operational plan. ## 6. Back up what the template hides Templates often make storage look invisible. It is not invisible when you lose it. For each deployed app, write down: - database name and engine - volume names - upload directory - config files outside the container - secret storage location - backup command - restore command Then run a restore drill somewhere disposable. A backup you have not restored is only a file with good intentions. For PostgreSQL, the first version can be simple: ```sh docker exec postgres pg_dump -U app app > app.sql ``` The final version should handle credentials, compression, retention, off-server storage, and restore testing. Start simple, but start. ## 7. Decide what gets monitored Do not monitor everything. Monitor the things that answer "should I wake up?" and "what changed?" For most template-deployed apps, monitor: - public HTTP availability - certificate expiry - disk usage - container restart count - database backup freshness - queue depth, if relevant - last successful deploy verification Notice the last item. A service can be up while the latest release is unverified. That state deserves attention, even if it is not a page. ## The template is the beginning One-click templates are useful because they remove repetitive installation work. They are not a substitute for operational confidence. The responsible pattern is simple: 1. Use the template to get the service running. 2. Inventory what it created. 3. Make logs searchable enough. 4. Add health checks that cover real behavior. 5. Verify every redeploy. 6. Prove rollback. 7. Back up and restore the data. That is the line between "I installed an app" and "I can safely run this app." The template gives you speed. The checklist gives you confidence. --- ## Coolify logs after Vercel — closing the route-level observability gap without OTel Source: https://deploytovps.com/blog/guide/coolify-route-level-logs-vercel-migrants Published: 2026-06-03 Tags: coolify, vercel, logs, observability, traceability, migration Developers leaving Vercel for Coolify hit the same wall: where are the searchable route-level logs? Here's the smallest setup that gets you there without a full OpenTelemetry stack. Vercel's dashboard spoiled a lot of developers. You ship a Next.js app, you click into the Functions tab, and there's the per-route latency, the recent errors, the request traces. The signal is there before you ask. Now you've moved off Vercel because the pricing math didn't work — congrats, that's healthy — and you've landed on Coolify. Deploy is fast. The dashboard is clean. Then you go to investigate a slow request and the observability gap hits. There's no per-route view. There's no searchable log surface. There's a Docker logs panel that's *fine* for last-five-minutes debugging and useless for "what happened at 3pm yesterday on /api/checkout?" The internet's answer is "set up SigNoz with OpenTelemetry." That's not wrong, but it's a weekend of work and a lot of moving pieces. There's a lighter path that recovers most of the Vercel-style insight in an afternoon. This guide is the lighter path. ## What you actually had on Vercel Before reaching for tools, it helps to name what Vercel was giving you. The bits that mattered: - Per-route latency aggregates (p50/p95) over a recent window. - Per-route error counts and the recent error responses. - A searchable log feed that filtered by route and time range. - Cold-start info (less relevant on Coolify since you're not on serverless). - A request-ID convention that tied logs to specific requests. Notice how much of this is *structured logging plus some aggregation*. There's not a lot of magic. The hard part on Vercel was the magic of "it just shows up"; the hard part on Coolify is having to wire the structured logging and aggregation yourself. ## The minimum useful setup The smallest setup that closes the gap has three pieces: 1. **Structured logs from the app.** Every request emits a JSON log line with timestamp, request ID, route, status, latency, and user-agent (plus whatever else you want). 2. **A log collector that can search.** Loki is the easy default for self-hosted; tail-based with grafana for the UI. Alternatives: Logtail, Better Stack, or just a SQLite-backed collector. 3. **A small rollup script.** Every minute, the script reads recent logs and writes per-route aggregates (count, errors, p50, p95) into a small store. This powers the dashboard. None of these require an OpenTelemetry collector. None of them require a separate metrics store. The structured log is the source of truth; everything else is derived from it. ## Step 1: structured logs in the app Whatever framework you use, drop a middleware that emits JSON log lines per request. For Next.js (App Router), in a route handler or a middleware: ```ts import { NextResponse } from "next/server"; export function middleware(req: Request) { const start = Date.now(); const requestId = req.headers.get("x-request-id") || crypto.randomUUID(); const res = NextResponse.next(); res.headers.set("x-request-id", requestId); res.headers.set("server-timing", `total;dur=${Date.now() - start}`); // Emit a JSON log line console.log(JSON.stringify({ t: new Date().toISOString(), rid: requestId, method: req.method, path: new URL(req.url).pathname, status: res.status, ms: Date.now() - start, })); return res; } ``` For Express: ```js app.use((req, res, next) => { const start = Date.now(); const rid = req.headers["x-request-id"] || crypto.randomUUID(); res.on("finish", () => { console.log(JSON.stringify({ t: new Date().toISOString(), rid, method: req.method, path: req.path, status: res.statusCode, ms: Date.now() - start, })); }); next(); }); ``` Different frameworks vary in the details. The shape is the same: one JSON line per request, with route, status, latency, request ID. ## Step 2: ship the logs into a searchable store Coolify deploys containers; Docker captures their stdout. The simplest path: - Install Loki + Promtail on your Coolify host (one Docker Compose file). - Promtail tails Docker logs and ships them to Loki. - Grafana (also a container) reads Loki and gives you the search UI. A minimal compose snippet: ```yaml services: loki: image: grafana/loki:latest ports: ["3100:3100"] volumes: ["./loki:/loki"] promtail: image: grafana/promtail:latest volumes: - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock - ./promtail.yaml:/etc/promtail/config.yml grafana: image: grafana/grafana:latest ports: ["3000:3000"] volumes: ["./grafana:/var/lib/grafana"] ``` With Grafana pointed at Loki, you now have a search UI: "show me lines where path=/api/checkout and status>=500 in the last 24h" returns results in seconds. This already recovers most of Vercel's log search feature. ## Step 3: a tiny per-route rollup For the latency aggregates Vercel showed, write a small script that runs every minute (via cron or a sidecar). The script queries Loki for the last minute's logs, parses them, and writes aggregates to SQLite or DuckDB: ```python # rollup.py import json, sqlite3, time, requests NOW = int(time.time() * 1000) WINDOW = 60 * 1000 rows = requests.get( "http://loki:3100/loki/api/v1/query_range", params={ "query": '{job="docker"}', "start": (NOW - WINDOW) * 1e6, "end": NOW * 1e6, "limit": 5000, }).json() buckets = {} for stream in rows["data"]["result"]: for ts, line in stream["values"]: try: d = json.loads(line) key = (d["path"], d["status"]) b = buckets.setdefault(key, []) b.append(d["ms"]) except Exception: pass conn = sqlite3.connect("/data/rollup.db") for (path, status), durations in buckets.items(): durations.sort() p50 = durations[len(durations)//2] p95 = durations[int(len(durations)*0.95)] conn.execute( "INSERT INTO rollup(ts,path,status,count,p50,p95) VALUES (?,?,?,?,?,?)", (NOW, path, status, len(durations), p50, p95)) conn.commit() ``` A single Grafana panel (or a Flask page) that reads this table gives you per-route latency over time. That's Vercel's function dashboard, minus the polish. ## Adding event overlays Vercel showed you the deploy line on top of the latency graph. You can replicate that: - On every successful deploy, emit a one-line event (JSON, structured). - Promtail picks it up. - The Grafana panel uses Loki's annotation feature to draw a vertical line at the deploy timestamp. Now when latency spikes after a deploy, you can see it in one glance. The same applies to cron jobs, restarts, or any operational event you want overlaid. The pattern is consistent: emit a structured log, surface it as an annotation. ## What this *doesn't* give you (and what to do about it) The lighter path doesn't cover everything Vercel did. Honest list: **Distributed traces.** If you have multiple services and want to follow a request end-to-end, the lighter path won't trace across services. Solution: pass the request ID through every internal call, and use Loki's search to manually correlate. For full tracing, you'll eventually want OpenTelemetry. **Real-user monitoring (RUM).** Vercel had Web Vitals built in. Lighter path doesn't. Add a small RUM script that posts metrics to a `/rum` endpoint, log them structured. **Sampling.** At very high volume, structured logs get expensive. Loki helps; eventually you'll want sampling. Not a day-one concern. **Auto-correlation.** Vercel grouped "all logs for this request" automatically. With the request ID convention you've added, you do the grouping by searching `rid=`. For most Vercel migrants on Coolify, the lighter path covers 80% of the use. The remaining 20% is OpenTelemetry territory; defer it. ## Operational notes A few real things to mind: - **Disk usage.** Loki at default settings can grow fast. Set retention to 14 or 30 days. Mount its data directory on a volume that's big enough. - **Loki + Coolify network.** Make sure your app containers and Loki containers are on the same Docker network, or expose Loki and have Promtail ship over TCP. - **Don't log secrets.** Structured logs are searchable; passwords and tokens should not be in them. - **Backups.** The rollup SQLite/DuckDB file is small; back it up. Loki's chunks are large; up to you. ## The order I'd recommend If you're a Vercel migrant on Coolify today, the smallest path forward: 1. Add structured logging middleware to your app. Deploy. 2. Verify Docker logs show the JSON format on your Coolify host. 3. Stand up Loki + Promtail + Grafana via Docker Compose. 4. In Grafana, configure Loki as a data source. Run a query for the last hour. 5. Build one panel: requests per route per hour. 6. Add one more panel: p95 latency by route. 7. Add event annotations for deploys. 8. Stop. Use what you have for a few weeks. Add more only when a specific need shows up. This trajectory gets you to a useful observability surface in an afternoon. From there, you can grow into OpenTelemetry when the system genuinely needs distributed tracing. ## The summary The Coolify observability gap is real for Vercel migrants. The fix isn't a full OTel stack on day one. It's structured logging in the app, Loki + Grafana for search, a small rollup script for per-route aggregates, and event overlays for deploys. That setup recovers most of the Vercel insight, costs an afternoon to build, and grows naturally into a heavier stack only if the system grows past it. --- ## Cron monitoring is really automation failure visibility — get the signal before the stack Source: https://deploytovps.com/blog/guide/cron-monitoring-is-failure-visibility Published: 2026-06-03 Tags: cron, scheduled-tasks, monitoring, alerts, backups, automation Operators ask for cron monitoring when what they want is to know that backups, syncs, and cleanups didn't silently fail. The fix starts with heartbeats, not a monitoring platform. Open the r/selfhosted thread asking about cron monitoring. The first comments are about Healthchecks.io, ntfy, gotify. Look closer at the question. The operator isn't actually worried about cron itself. They're worried that their *backups didn't run last night*. That their *sync job failed silently*. That their *cleanup didn't fire and the disk filled up*. The cron is just where those jobs live. The ask is for *automation failure visibility*. The right tools match that need — they answer the questions the operator is actually losing sleep over. This guide walks through the visibility problem in order: what to monitor, how to monitor it without standing up a stack, and when to upgrade. ## What "silent failure" actually looks like Cron's design is famously quiet. A job that fails leaves an exit code; cron does nothing visible with it. If the job's own logs aren't being read, the operator finds out *after* something downstream breaks. A few common silent failures: - The backup script ran every night for two months. The last week it's been exiting non-zero before the upload step. The restore drill on day 70 reveals the gap. - The cleanup job is supposed to delete files older than 30 days. A path was misconfigured; it's been deleting nothing for months. The disk is now full. - The sync job is supposed to push reports to S3 every morning. The credentials expired three weeks ago; the job has been quietly failing every morning since. - The cert renewal hook is supposed to reload nginx after a renewal. The reload hasn't been running; users are seeing expired certs. In each case, *cron worked perfectly*. The job that ran inside it didn't. And the operator had no signal until something downstream failed. This is why cron monitoring is really *automation failure visibility*. The point isn't to know that cron ran. The point is to know that the *automation* did what it was supposed to. ## The minimum useful pattern: start + end pings The cheapest pattern that gets you most of the visibility: Each job pings a unique URL when it starts, and again when it finishes successfully. A central service knows the schedule. If a ping is missed, the service alerts. ```sh #!/bin/sh set -e URL="https://hc-ping.com/" curl -fsS -m 5 --retry 3 "$URL/start" ./real-job.sh curl -fsS -m 5 --retry 3 "$URL" ``` This pattern catches: - **Non-runs.** The cron didn't fire at all. The service notices the missing start ping. - **Failures.** The job started but exited non-zero. The service notices the missing finish ping. - **Hangs.** The job started but didn't finish before its deadline. The service notices the missing finish ping. All three are common. None of them are visible without a heartbeat layer. With it, you find out within minutes of the deadline instead of weeks later. ## What the central service has to do The simplest service: - Knows the schedule of each job. - Knows the deadline for completion. - Tracks the most recent start and finish times. - Sends an alert (email, push, webhook) when a deadline is missed. Healthchecks.io is the canonical hosted version. Self-hosted alternatives include `healthchecks-io` (the self-hostable open-source codebase), `cron-mon`, or a 50-line Python script with SQLite and a cron of its own that checks for missed deadlines. The self-hosted route is appropriate if you have many jobs, want to avoid an external dependency for monitoring, or are running in a network without outbound HTTP. ## Adding context to the failures A bare missed-ping alert tells you something failed, not what. The next move is to make the failures explain themselves. **Capture exit codes.** The wrapper script captures the exit code and posts it as part of the failure ping or as a separate event. **Capture last lines of output.** When the job fails, the wrapper captures the last 50 lines of stderr and includes them in the alert. **Tag jobs.** Each job has a stable name, a category ("backups", "sync", "cleanup"), and an owner. Alerts include the tags. **Differentiate severity.** A failed backup is critical. A failed cleanup is a warning. The service should respect that distinction so on-call isn't paged for everything. None of this requires a heavyweight stack. It's mostly discipline in the wrapper script and in how the central service is configured. ## What goes in the wrapper script A reasonable production wrapper for any cron job: ```sh #!/bin/sh set -eu JOB="$1" URL="https://hc-ping.com/${HC_TOKEN}/${JOB}" LOG=$(mktemp) trap "rm -f $LOG" EXIT curl -fsS -m 5 "$URL/start" >/dev/null if "$@" >"$LOG" 2>&1; then curl -fsS -m 5 --data-binary @"$LOG" "$URL" >/dev/null else EXIT=$? tail -n 50 "$LOG" | curl -fsS -m 5 --data-binary @- "$URL/fail?code=$EXIT" >/dev/null exit $EXIT fi ``` This wrapper: - Pings start. - Captures the job's stdout/stderr to a temp file. - On success, posts the log to the success URL. - On failure, posts the last 50 lines and the exit code to the fail URL. - Cleans up the temp file regardless. Use it as `cron-wrap backup-nightly ./backup.sh /data` — every cron line follows the same pattern. ## Categories worth monitoring carefully Not every cron is equal. A few categories deserve extra-careful visibility: **Backups.** A silent backup failure is a future restore failure. Always heartbeat. Always alert on failure. **Cleanups.** A silent cleanup failure ends in a disk fill or a quota breach. Always heartbeat. Alert at warning severity. **Sync jobs.** Silent sync failures mean data is missing somewhere downstream. Always heartbeat. Alert at warning severity, escalate after N missed runs. **Cert and credential renewals.** Silent renewal failure means a user-visible outage when the cert or key expires. Always heartbeat. Alert at critical severity. **Report generation.** Silent report failure usually shows up when somebody asks where the report is. Heartbeat. Alert at low severity. The pattern is the same; the severity differs. Get the patterns in place; tune the severity later. ## What to defer A few things people mistakenly add too early. **Full monitoring stacks.** Prometheus, alertmanager, exporters for cron metrics — overkill for the cron-monitoring problem. The heartbeat layer answers the question. **Distributed scheduling.** Airflow, Argo, Temporal — useful when jobs are complex DAGs with dependencies. For "the nightly backup runs at 3am," plain cron + heartbeats is fine. **Custom dashboards.** Most operators look at the dashboard zero times per week as long as the alerts are working. Don't build a dashboard until you find yourself wanting one. **Cron clustering.** Useful if you really do need fault tolerance for the scheduler itself. Almost nobody does. Defer all four. Heartbeats are the load-bearing infrastructure. ## When the heartbeat layer isn't enough A few signals tell you it's time to upgrade: - You have dozens of related jobs whose dependencies matter. - You need to retry failed jobs automatically with backoff. - You need cross-job audit ("which jobs touched this customer in the last week?"). - You have an SLA that includes job freshness. - You're running on behalf of customers, not just yourself. When those conditions hit, look at Cronicle, then at a proper scheduler. Until then, heartbeats are doing the work. ## The cultural shift The biggest gain from heartbeating every job isn't technical. It's cultural. The team stops treating "cron didn't fire" as a thought-experiment and starts treating it as a known monitored condition. A new job, by team convention, ships with a heartbeat or doesn't ship. The next operator coming into the codebase sees the wrapper and knows the discipline. The thing that used to be a quiet failure mode becomes a visible one. Over months, the system gets less spooky. ## The summary When people ask about cron monitoring, they want *automation failure visibility*. The right pattern is heartbeats — start and finish pings to a central service that alerts on missed deadlines. Wrap every job. Capture exit codes and log tails. Tag and categorize. Defer the heavy stacks. The result is that silent failures stop being a category that exists. You find out within minutes of the deadline; you don't find out via the downstream consequence. --- ## Dokploy server-to-server migration — volume-safe checklist for Hetzner moves Source: https://deploytovps.com/blog/guide/dokploy-server-to-server-migration-checklist Published: 2026-06-03 Tags: dokploy, migration, backups, volumes, cutover, hetzner Dokploy migrations look like 'export the panel and import on the new server.' The volume question is what makes it actually risky. Here's the volume-safe migration checklist. If you've spent any time in r/dokploy, the migration thread is familiar: "What's the best way to move my Dokploy install from one Hetzner server to another?" The replies always start with "export the panel and import on the new one," and then immediately someone asks: "what about the volumes?" That second question is the real one. The panel export is easy. The volumes — the data that lives outside the panel's config — are where migrations get dangerous. A successful Dokploy migration is a successful *volume* migration. The panel is the small part of the work. This checklist is the order of operations to get a Dokploy migration done without losing data. ## What the panel export actually covers Dokploy's export gives you: - The list of applications, services, and databases the panel manages. - Their environment variables (encrypted with the panel's key). - The deployment configurations (build commands, ports, domains). - The user accounts and roles. - Some platform settings (Traefik labels, networks). What it does *not* cover: - The Docker volumes that store database data. - Application-specific bind mounts. - Build caches and image layers. - TLS certificates managed at the Traefik level. - Any data Dokploy doesn't know about (manually-created containers, side scripts, etc.). The export covers the *configuration*. The migration has to also handle the *state*. ## The migration order Do these in order. Each step assumes the previous one is complete and verified. ### 1. Inventory everything On the old server, list: - All applications managed by Dokploy: `dokploy app list` (or via the UI). - All databases: `dokploy db list`. - All Docker volumes: `docker volume ls`. Note which volumes belong to which app/db. - All bind mounts from `docker inspect ` for each running container. - Custom Traefik certificates if you use any (most setups use Let's Encrypt and don't need to migrate). - DNS records pointing at the old server. Write this inventory to a file in your repo. The migration is a checklist; the checklist is data. ### 2. Confirm backups exist and restore Before you touch the migration, confirm: - Database backups exist for every database (Dokploy can do this; it does *not* do it automatically). - The backup is recent (within hours of the planned migration). - A test restore works. Spin up a side container and restore the latest backup. Verify the app can read it. If any of these fail, fix them before continuing. A migration without verified backups is a migration with no rollback. ### 3. Provision the new server On the new Hetzner box: - Same OS family as the old one. Don't mix Ubuntu and Debian if you can avoid it; minor differences will bite. - Install Docker the same way you installed it on the old box. - Install Dokploy the same way. - Set the new server's hostname to something distinct. - Open the firewall ports Dokploy needs (typically 80, 443, 22, and Dokploy's UI port). - Confirm you can `ssh` and reach the Dokploy UI on a non-DNS URL (IP + port). Do *not* point any production DNS at the new server yet. ### 4. Import the panel export Export from the old server via Dokploy's UI. Import to the new server. Verify: - All applications appear in the new panel. - Environment variables are present (might need re-entering the panel encryption key). - Deployment configs look correct. The new panel now *knows about* the apps but they haven't been deployed yet. ### 5. Migrate the volumes This is the load-bearing step. For each app that has volumes: #### For databases The safest move is to use the database's own backup/restore. Don't rsync a live Postgres data directory; you'll corrupt the destination. - Take a fresh logical backup on the old server (`pg_dump`, `mysqldump`). - Copy the backup file to the new server. - Spin up the empty database container on the new server (via the imported Dokploy config). - Restore the backup into the new container. - Verify a sample query returns the same row count as the old server. #### For app volumes (uploads, caches, etc.) Use `rsync` while the old service is still up: ```sh # On the new server: rsync -avz --delete root@old-server:/var/lib/docker/volumes//_data/ /var/lib/docker/volumes//_data/ ``` Do this *twice* — once now, once during the cutover. The first pass moves most of the data; the second pass catches anything that changed during step 6. ### 6. Deploy the apps on the new server With volumes in place, trigger a deploy of each app in Dokploy on the new server. The container starts; it sees the migrated volume; it should boot to the same state the old server was in. Verify each app: - The home page loads. - A protected page (requires DB) loads. - File uploads still work. - An action that writes to the DB still works. If any of these fail, *do not proceed to DNS cutover*. Diagnose first. The new server hasn't taken traffic yet. ### 7. The cutover When the new server passes verification: - Put the old server's apps into a maintenance state (Dokploy can scale to 0, or you can stop the containers). - Do a final rsync pass for app volumes (catch any updates since the previous sync). - For databases, take one more backup on the old server and restore on the new one — or use logical replication if you set it up earlier. - Update DNS: lower the TTL on the relevant A/AAAA records to 60 seconds 24 hours ahead of cutover; flip the records to the new IP. - Watch DNS propagation. Most resolvers update within a few minutes if TTL was lowered. - Verify traffic is hitting the new server (check Dokploy logs or the reverse proxy access logs). - Confirm TLS works (Let's Encrypt will re-issue against the new server's IP; verify it succeeds). The cutover window is the only time both servers are simultaneously "in service." Keep it short. ### 8. Post-cutover verification After cutover: - Re-run the same verification queries you ran in step 6. - Check error logs on the new server for the first hour. - Watch the database for unusual load. - Confirm scheduled jobs (cron, panel-scheduled) are firing on the new server. - Wait at least 24 hours before turning off the old server. Keep it as a hot rollback target. If nothing has gone wrong in 24 hours, you can begin decommissioning the old server. ### 9. Decommission the old server When you're confident: - Take one last backup of all databases from the old server. Keep it offsite for at least 30 days. - Snapshot the old server's volumes the same way. - Document anything migration-specific in your runbook. - Turn off the old server; cancel after the 30-day backup retention window. Don't skip the 30-day window. Migration issues often surface days or weeks later. ## Common failure modes and how to avoid them **Postgres data directory copied while live.** Symptom: new server's Postgres won't start, or starts with corruption. Fix: always use `pg_dump`/`pg_restore` or proper file system snapshots, never a live `rsync` of the data directory. **Environment variables missing on new server.** Symptom: app starts but can't reach services. Fix: Dokploy's export sometimes loses env vars that were set outside the panel UI; cross-check from your local notes or the old container's `docker inspect`. **TLS certificate fails after cutover.** Symptom: browsers show cert errors after DNS flip. Fix: Let's Encrypt has rate limits; if you bounced certs too many times, you might need to wait. Mitigate by lowering DNS TTL ahead of time so the flip is fast. **Stale DNS resolvers.** Symptom: some users see the old server, some see the new. Fix: pre-lower TTL by 24 hours; expect a small window of mixed traffic. **Volume permissions mismatch.** Symptom: app starts but can't write to its volume. Fix: check that the UID inside the container matches the UID on the host (`docker exec` and `id` vs `ls -ln` on the host path). ## What this list deliberately doesn't cover A few things people sometimes try to fold in: - **Upgrading Dokploy version during migration.** Don't. Migrate first, upgrade second. Two changes at once obscures which one broke things. - **Changing hostnames or domains.** Don't. Keep everything identical; change one variable at a time. - **Migrating to a different OS.** Possible, but add a step to test for OS-level differences first. The shorter you keep the change set during cutover, the easier the diagnosis when something does go wrong. ## The summary Dokploy migrations look like "export and import." The real risk is volumes — both the database data and the app files. The migration that succeeds: verified backups first, side-by-side new server with imported config, volumes migrated and verified before any DNS change, fast cutover, 24-hour rollback window, 30-day final backup. Each step protects the next one. Don't compress the order to save time; the time is in the rebuild if you skip a step. --- ## Pi plus NAS — the practical self-host starter if you keep mounts and backups boring Source: https://deploytovps.com/blog/guide/pi-plus-nas-practical-self-host-starter Published: 2026-06-03 Tags: raspberry-pi, nas, backups, self-hosting, homelab A Raspberry Pi paired with a NAS is the right starter stack for self-hosting — but only if you keep the storage paths, mount conventions, and backups boring. Here's the discipline that makes it durable. If you're starting a self-host setup and you don't already have strong opinions, a Raspberry Pi paired with a NAS is the right shape. Cheap, low-power, expandable, and well within the operating envelope of a hobbyist who's also doing other things. The usual failure mode isn't the hardware; it's the impulse to make the storage layer interesting. People stack ZFS over RAID over a custom mount-point scheme, add bind mounts for "organization," and put backups behind a homemade script that runs sometimes. Two months later, a disk acts up and the recovery is harder than it should be because nothing about the storage is *boring*. The Pi-plus-NAS stack is durable when you keep the storage boring. This guide is the boring playbook. ## The architecture in one paragraph A Pi (or several) is the compute layer. A NAS (Synology, QNAP, or DIY with TrueNAS) is the storage layer. The Pi mounts shares from the NAS over NFS or SMB. Application containers on the Pi keep their *configuration* on the Pi's local SD card or SSD and their *data* on the NAS share. Backups run from the NAS, not from the Pi. This split is the whole game. Compute is cheap and replaceable; storage is the part that has to last. Keep them separate. ## The naming conventions that prevent half the future pain Storage paths drift in the worst way. The conventions worth committing to up front: **One root mount per NAS share.** `/mnt/nas/` and never anywhere else. Don't bind-mount the same share into ten places. If you want shorter paths, symlink — but the symlinks all point to one root. **One directory per app.** Under each share, one directory per application. `/mnt/nas/apps/nextcloud`, `/mnt/nas/apps/jellyfin`. Apps never share directories; never "oh, both of these read the same folder." **Predictable subdir layout per app.** Inside the app's directory, the same subdirs: `data/`, `config/`, `cache/`. The application's container is mounted at the right subdirs. **Hostname-aware paths if you have multiple Pis.** If two Pis run the same app, paths include the hostname: `/mnt/nas/apps//nextcloud`. Otherwise their data clobbers each other. These feel obvious and they get violated *all the time* by half-asleep operators. Stick to them. The reward shows up when you migrate Pis, swap disks, or restore from backup six months later. ## The mount layer NFS or SMB? Use NFS when the NAS supports it and your Pi is Linux. SMB if you must. NFS mounts go in `/etc/fstab`: ``` nas.local:/volume1/apps /mnt/nas/apps nfs defaults,_netdev,nofail,x-systemd.automount 0 0 ``` Key options: - `_netdev` tells systemd this is network-dependent. - `nofail` so the Pi still boots if the NAS is offline. - `x-systemd.automount` so the mount is brought up lazily on first access rather than blocking boot. Without these three, you'll have Pis hanging at boot because the NAS isn't reachable yet. With them, the Pi boots; the mount comes up when something asks. For write-heavy apps (Nextcloud, Jellyfin metadata), consider NFSv4 with `actimeo=1` to reduce attribute caching weirdness. ## Where Docker volumes live This is the question that trips people up. Should Docker volumes live on the Pi's local SSD, or on the NAS? The practical answer: **app data goes on the NAS, but app config and small caches can live locally.** Why: - App data is large, durable, and needs backup. Put it on the NAS. - App config (the YAML, the SQLite databases that the app uses for its own settings) is small and reads frequently. NFS reads are slower than local SSD reads; the user notices. - Caches are recreatable. Put them locally; they'll regenerate if lost. In a Compose file, this maps to bind mounts that explicitly choose the right side: ```yaml volumes: - /var/lib/myapp/config:/config # local SSD - /var/lib/myapp/cache:/cache # local SSD - /mnt/nas/apps/myapp/data:/data # NAS ``` This split keeps the app fast and the data safe. ## Backups: the discipline that decides everything A Pi-plus-NAS setup lives or dies by backups. The boring playbook: **Two destinations, always.** The NAS is one. An offsite location is the other — Backblaze B2, AWS S3, a Hetzner Storage Box, or a friend's NAS. "Backups" with one destination is a synonym for "copies." **Use a real backup tool.** `restic` is the popular default for a reason: encrypted, deduplicated, verifiable. `borg` is the other strong choice. Don't write your own. **Schedule via cron on the NAS, not the Pi.** The NAS already runs 24/7 and has direct access to all the data. Pi reboots shouldn't interrupt backups. **Heartbeat every backup.** Use the [cron monitoring pattern from this site](https://deploytovps.com/guide/cron-monitoring-is-failure-visibility). If the nightly backup doesn't ping, you find out within the hour. **Test restores monthly.** Pick a small app. Restore its data to a side directory. Spin up a container against it. Verify the app reads the restored data correctly. If it doesn't, your backup is theoretical, not real. **Document the restore.** "To restore Nextcloud: 1. restore /mnt/nas/apps/nextcloud from latest snapshot. 2. point Compose at the restored path. 3. start the container. 4. verify the home view." Write this *for each app* and keep it in your homelab repo. A setup with verified offsite backups, monthly restore drills, and documented per-app restore procedures is genuinely resilient. A setup without those is one disk failure from a bad weekend. ## Why people overcomplicate this The most common mistakes: **Trying to use the NAS for compute too.** Synology and QNAP boxes can run containers. They shouldn't run your main self-host workloads — they're underpowered, their container support is a second-class citizen, and you've now made the storage box also the compute box. If the NAS reboots, your apps go down. Keep compute on Pis. **Tiered storage on the NAS.** Building a fancy pool layout with hot and cold tiers. For a hobbyist self-host setup, one pool with one RAID level is fine. Tier when you outgrow it. **Encrypted everything everywhere.** Encrypting the NAS at rest is reasonable. Encrypting individual Docker volumes on top of that is usually unnecessary friction. **A bespoke mount script.** A custom shell script that mounts everything in the right order. Use systemd and `_netdev`; let it work. Each of these adds operational surface for not much benefit at the starter scale. ## What to add in stage two After the Pi-plus-NAS setup has been running stably for a few months and you've drilled the backup restore at least twice, the natural extensions: - **A second Pi for redundancy on critical services.** Keep state on the NAS; the second Pi takes over if the first dies. - **A small monitoring layer.** Either the [lightweight observability pattern](https://stoicsoft.com/lightweight-observability-for-self-hosters) or a single Grafana panel reading from the rollup. - **A VPN into the homelab.** Tailscale is the easy default. Don't expose anything to the public internet you don't have to. - **A reverse proxy.** Caddy or Traefik on the Pi. TLS for everything, including internal apps. - **Snapshots on the NAS.** Synology and TrueNAS both make periodic local snapshots cheap. They protect against accidental deletes. Each of these earns its place after the storage layer is already boring. Adding them before that point amplifies the chaos. ## A short checklist for week one If you're standing the Pi-plus-NAS setup up this week, the minimum: - [ ] NAS configured with a single pool and one share per category (apps, media, backups). - [ ] Pi fstab uses NFS with `_netdev` and `nofail`. - [ ] Each app's compose file uses explicit bind mounts split between local config/cache and NAS data. - [ ] One backup destination (NAS local snapshots) + one offsite destination (restic to B2 or equivalent). - [ ] Backup heartbeat configured for the offsite job. - [ ] One restore drill completed end-to-end on one app. - [ ] Restore procedures documented in your homelab repo. That's the boring baseline. From there, add apps without changing the storage discipline. The setup will hold for years. ## The summary Pi-plus-NAS is the right shape for starter self-hosting. The durability comes from the storage discipline, not the hardware. Keep paths boring, mounts predictable, backups verified, and restores drilled. The hardware stuff people obsess over is downstream of those four. Get the storage layer right; the rest of the homelab grows on top without surprise. --- ## Rollback and alert context in the same place — design for the 3am decision Source: https://deploytovps.com/blog/guide/rollback-plus-alert-context-same-place Published: 2026-06-03 Tags: rollback, alerts, deploy-monitoring, vps-ops Rollback alone isn't enough when a deploy fails at night. Operators need the health signal, log context, and recovery action wired together so the 3am decision is fast. It's 3am. Your phone goes off. Something in production is unhealthy. You stumble to your laptop. The first question is: what changed? If your alert tells you a deploy happened thirty minutes ago, your next move is obvious: roll it back. If your alert tells you nothing about deploys, you're now in a separate window checking deploy history, then another window checking logs, then another window deciding what to do, all while sleep-deprived. The hard part of the incident isn't the rollback — it's the *navigation* to the rollback. The fix is to wire rollback and alert context into one surface. The 3am operator should see: what's wrong, what changed recently, what the logs look like, and the one button to revert. Not in four tools — in one. This guide is the design that makes that possible on a normal small-to-medium VPS setup. ## What the 3am decision actually needs Let's enumerate the information an operator needs to decide "roll back yes/no": 1. **What's broken.** Which service, which endpoint, which symptom (high errors, high latency, missing heartbeat). 2. **What changed recently.** Most outages are caused by changes. A deploy in the last hour is the prime suspect. So is a config push, a cron run, an external dependency event. 3. **A log excerpt.** Not full logs — the last few error lines from the affected service. 4. **The current health state.** Is it still bad, or did it self-heal? 5. **The rollback action.** A one-click or one-command revert if the change is the cause. Five items. They have to be in the same view. Whatever tool surfaces the alert should also surface the other four. Most alerting setups give you item 1 and nothing else. "High error rate on api-prod." The operator now has to navigate to find 2, 3, 4, and 5 — across tools, at 3am. ## The shape that fixes this The target shape: a single channel (Slack, Discord, email, whatever) where the alert message is a *card* containing all five items. A card might look like: > **🔴 api-prod: error rate 18% (threshold 1%)** > > **Last change:** deploy to api-prod 22 min ago, SHA `abc123`, by ci/cd. > > **Recent errors:** > ``` > ERR 500 /api/checkout TypeError: order.customer is undefined > ERR 500 /api/checkout TypeError: order.customer is undefined > ERR 500 /api/cart TypeError: cart.items is null > ``` > > **Current health:** still degrading. > > [Roll back to previous] [Acknowledge] [Open dashboard] With this card, the 3am decision is twenty seconds: read, recognize the deploy, click roll back, watch health recover, go back to sleep. Without this card, the decision is twenty minutes of context-gathering and second-guessing. ## How to build the card on a small VPS setup Four pieces have to come together. None of them are heavy. ### 1. Alerts that know about deploys Your alerting layer needs access to the deploy event log. The simplest way: every deploy emits a structured event into the same log or DB that your alerts read from. Then the alert template can include the most recent deploy event in the rendered message. If you use the [lightweight observability pattern](https://stoicsoft.com/lightweight-observability-for-self-hosters), deploy events are already in your rollup. The alert template just needs to query "latest deploy event affecting this service in the last hour." If you use a separate tool like Prometheus alertmanager, give it a custom template that includes a `recent_deploy` field from the same source. ### 2. Alerts that include log context The alert payload should include a snippet of recent error logs. The pattern: - When the alert fires, your alerting service queries Loki (or your log store) for the last N error lines matching the affected service in the last 5 minutes. - Those lines are embedded in the alert message. The rendered card now has both "what changed" and "what does the error look like" without the operator opening anything else. ### 3. A health probe in the alert thread When the alert fires, kick off a recurring health probe that posts updates into the same alert thread every minute. "01:23 still degraded. 01:24 still degraded. 01:25 recovering. 01:26 healthy." This tells the operator whether to act *now* or whether to wait one more minute. It also creates a record of how the incident progressed. ### 4. Rollback as a one-button operation The alert card includes a button or command for rollback. On Slack, this is an interactive button that triggers a webhook. On Discord, a button component. On email, a one-line CLI command the operator can run from their phone via SSH. The rollback target is the *previous* SHA before the latest deploy, derived from your deploy event log. The rollback itself is a script — stop the current container, run the previous image, verify the version endpoint. The button just invokes the script. ## Implementation paths by stack **If you use Coolify or Dokploy:** both have webhook support for deploys. Wire your alerting service to consume the webhook and write deploy events to your event store. Rollback is `coolify rollback` or the equivalent in Dokploy. **If you use Docker Compose directly:** your deploy script writes an event to a small SQLite or DuckDB file before swapping images. Your alerting service reads from that file. Rollback is a script that re-runs the previous image tag. **If you use Kubernetes:** deploy events are first-class. Rollback is `kubectl rollout undo`. Same alerting card shape; different rollback action. In all three cases the shape is identical: alert + change + logs + health + action, all in one card. ## The hard cases A few situations the simple card doesn't fully cover. **Rollback can't fix the issue.** If the bad state is in the database (a migration ran that the previous image doesn't expect), rolling back the container is dangerous. The card should warn: "this deploy ran migrations; rollback may require a DB restore." Knowing this *in the alert* is what prevents a 3am operator from making the bad call. **External dependency issue.** The deploy didn't cause it; an upstream service did. The card's "last change" field would say "no recent deploy." The operator should see this and not roll back. Worth surfacing "no recent change" explicitly rather than leaving it blank. **Multiple recent changes.** A deploy plus a config push plus a cron run, all within the alert window. The card lists them ordered by time. The operator can roll back the deploy, the config, or neither. **Cascade failures.** The alerting service alerts on a downstream service whose health depends on an upstream that quietly failed. The card should include upstream-service health checks where possible. None of these are deal-breakers; they're the cases that justify *thinking* about the alert design rather than just turning on default thresholds. ## What this does to MTTR The time-to-recovery improvement from a well-designed card is significant. Without the card: 3am operator wakes up, opens four windows, hunts through deploy history, decides, rolls back. Twenty to forty-five minutes from alert to recovery. With the card: 3am operator reads the card, clicks roll back. Two to five minutes from alert to recovery. Most of the time saved isn't the rollback itself — it's the navigation. The card pre-loads the operator's context. ## What this does to alert fatigue A secondary benefit: when the alert card includes context, the operator can quickly tell whether an alert is *real* or noise. - Recent deploy + errors in deployed routes + health degrading → real, act. - No recent deploy + errors in a single route + health flapping → probably noise from a flapping dependency. - No recent deploy + steady error rate slightly above threshold → probably need to retune the threshold, not panic. Without context, every alert demands the same attention. With context, the operator triages in seconds. Over months, the difference is night and day. ## What to skip A few patterns that look helpful and aren't: **Detailed metrics graphs in the alert.** The operator doesn't need a graph in the alert; they need the conclusion. Save the graph for the dashboard the alert links to. **Auto-rollback.** Tempting, dangerous. Auto-rollback presumes the deploy is the cause. Sometimes it isn't. Auto-rollback is for environments with high-quality automated tests catching everything; if you don't have those, leave the human in the loop. **Multi-channel alerting.** Posting the alert to four channels (Slack, PagerDuty, email, SMS) sounds robust. In practice it means four notifications and four threads to manage. Pick one channel; make it work. **Custom incident management UI.** Operators don't want another tool. The card in the chat channel is enough until your team is big enough to need formal incident management — which is well past the scope of small-to-medium VPS ops. ## A short checklist To wire this on your own setup: - [ ] Every deploy writes a structured event to a queryable store. - [ ] Alerts render a card containing: title, last change, recent error logs, current health, rollback button. - [ ] Alerts post to a single chat channel where the operator already lives. - [ ] A health probe posts updates into the alert thread every minute until recovery. - [ ] The rollback button maps to a one-command script that reverts the deploy and verifies via the version endpoint. - [ ] Migration-bearing deploys are tagged; the card warns on rollback when migrations were run. With these in place, the 3am decision becomes seconds-fast. Without them, it stays the worst part of running production. ## The summary Rollback alone isn't enough. The 3am operator needs alert + change + logs + health + action in one card, in one place. Design the alert pipeline so deploy events, log snippets, health probes, and rollback actions converge into the same notification. The improvement to MTTR and to operator quality of life is large, and it doesn't require a heavy incident-management stack to ship. --- ## From two-VPS SaaS to observability, rollback, and HA — the maturity arc nobody warns you about Source: https://deploytovps.com/blog/guide/two-vps-saas-observability-rollback-ha-arc Published: 2026-06-03 Tags: vps, saas, rollback, ha, observability, wireguard, docker-compose Running a SaaS on two cheap VPSes with Compose and WireGuard feels great until the conversation turns to backups, rollback, and observability. Here's the arc, in order, with what to add when. There's a popular Reddit shape: "I run a SaaS on two Contabo VPSes with Docker Compose and WireGuard between them." The thread fills up fast. The first replies are admiring. Within ten comments, the conversation shifts: "what about backups?" "how do you handle rollback?" "what about HA?" "do you have observability?" The pattern is real. A two-VPS Compose-and-WireGuard setup is the right shape to *start* with — cheap, debuggable, no Kubernetes tax. It's also the shape where the operator has to walk a specific maturity arc before the system can handle real traffic, real customers, and real outages. This guide is the arc in order. What to add first, what to defer, and where the curves bend. ## The starting point: two VPS, Compose, WireGuard The default setup the threads describe: - Two VPSes, often Contabo or Hetzner. - App on one VPS, database on the other. - WireGuard between them for a private network. - Docker Compose orchestrates each box's services. - A reverse proxy in front of the app box; the DB box is private. - SSH access from the operator's workstation. This is genuinely a defensible architecture for a small SaaS. It's simple. It's debuggable. It scales further than people expect. It's also missing five things you'll need before you can run it in front of real customers without anxiety. ## Stage 1: backups (week one) Nothing else matters if you can't recover from a bad day. Backups are stage one. **What to back up.** The database (full snapshot + WAL or binlog), the volumes that hold user uploads, the secrets that aren't in source control, and the config files that aren't templated from your repo. **Where to back them up.** Off the VPS. The whole point is that the VPS could die. Hetzner Storage Box, Backblaze B2, or AWS S3 are all reasonable. Pick one and stick with it. **How often.** Database snapshots: daily, with WAL/binlog continuously archived. Volumes: daily or as often as the data changes meaningfully. Configs: on every change, ideally via the repo. **Verify the restore.** A backup you've never restored isn't a backup. Spin up a third VPS once and restore the latest backup end-to-end. Verify the app boots against it. Make this a monthly drill. **Encrypt.** Backups going off-VPS should be encrypted at rest. `restic` with a strong passphrase is the easy default; provider-side encryption is fine but doesn't protect you if their key is compromised. Until all five of these are checked, the two-VPS setup is one bad disk away from a bad story. Get them done first. ## Stage 2: rollback discipline (week two) The first stage protected you from data loss. Stage two protects you from your own deploys. In a Compose-and-WireGuard setup, rollback is rarely a single command. It's a discipline: **Tag every release.** Every image gets a unique tag — usually the git SHA. The image with `:latest` is *also* tagged with `:`. You always know what was running. **Keep the previous image around.** Don't `docker image prune` the recent images. Keep at least the last three. Disk is cheaper than downtime. **Make rollback a one-command operation.** `./rollback.sh ` should stop the current container, run the old image, and verify. Don't make the on-call person figure out the command at 2am. **Pre-flight before deploy.** The deploy script runs basic checks before swapping containers: image pulled, env vars present, database reachable, current container still healthy. If any fail, deploy aborts. **Verify after deploy.** After the new container starts, hit a `/version` endpoint and confirm the live response shows the new SHA. If it doesn't, the deploy is treated as failed. This is not the same as zero-downtime deployment. It's the *recoverability* of a deploy that's about to ship. Get this right before chasing zero-downtime. ## Stage 3: visibility (week three) You can't fix what you can't see. Stage three is making the system observable enough to debug. **Structured logs.** Each app log line is JSON: timestamp, level, request ID, route, status, latency. The reverse proxy logs are also structured. Both are forwarded to a central place — Loki, Logtail, or just a file on a backup box. **A small metrics rollup.** Every minute, a script reads recent logs and writes rollups: requests by route, errors by route, p50/p95 latency. Stored in SQLite or DuckDB. Thirty days retained. **Event overlays.** Deploys, restarts, and migrations emit one-line events into the same store. When you debug a spike, you can see the deploy that preceded it. **A one-page dashboard.** Doesn't have to be Grafana. A Flask page that reads the rollups is enough. The point is that *one* URL answers "what's happening?" This is the [lightweight observability pattern](https://stoicsoft.com/lightweight-observability-for-self-hosters) — and it's the right shape for a two-VPS setup. Save SigNoz and OpenTelemetry for when the system has grown past it. ## Stage 4: real downtime testing (week four) Before you can claim HA, you have to know what happens when things fail. Stage four is *running* the failure cases on purpose. **App VPS dies.** What happens? In the bare two-VPS setup, the answer is "site is down." You need to know this with certainty before you can plan around it. **Database VPS dies.** The app comes up but can't connect. Most apps handle this badly. Test it now; you don't want to learn at 3am. **WireGuard fails.** App can't reach DB. Same shape as DB-down for the app's perspective, but the recovery is different. Test it. **Reverse proxy crashes.** All inbound traffic stops. The app is fine; the front door isn't. This is the failure mode the operator usually didn't plan for. **A deploy ships a broken release.** Without rollback discipline, this is a real outage. With it, it's a fifteen-second blip. Running these as drills surfaces the gaps that the architecture would have hidden until they hit you in production. ## Stage 5: HA, when needed Full HA is the most expensive thing on this list. It's worth doing — but only after the previous four stages are solid. **App-layer HA.** Two app VPSes behind a small load balancer (HAProxy, Caddy with multiple upstreams, or a managed LB). Stateless app design lets this work. WireGuard mesh now connects all three boxes. **Database HA.** This is the hard part. The simple version is a read replica. The full version is automatic failover with something like Patroni for Postgres or MariaDB Galera. Both add real operational burden. **Network HA.** If your SaaS is global, you'll eventually want multi-region. This is a bigger change than HA within a region. Do not skip to this stage. Two-VPS without backups or rollback is more dangerous than two-VPS with backups and rollback. HA without observability means you can't tell when failover happened. The order matters. ## What "good" looks like after the arc After walking the arc, a two-VPS-plus-LB setup with: - Daily verified offsite encrypted backups. - Rollback as a one-command operation, verified by version endpoint. - Structured logs, lightweight metrics, deploy events overlaid. - Documented failure drills, run quarterly. - App-layer HA via a small LB. - Postgres or MySQL with a read replica. - Status page or at least an external uptime monitor. …is good enough to run a paying SaaS without dread. It's not "enterprise." It is *durable*. The customers don't notice the difference. ## When the arc doesn't fit A few situations where the arc above is the wrong choice: - **Heavy compute or specialized hardware.** Move to managed earlier; two VPSes won't grow into it. - **Regulated data with strict residency.** The off-VPS backup decision gets harder; use a provider that meets the regulation. - **Spiky traffic far beyond steady state.** Autoscaling matters; cloud-native is probably the right shape. - **Single-developer side project with no paid users.** Do stage 1 only and stop. Don't over-engineer. Most SaaS that fits the two-VPS shape will benefit from the arc as written. ## What the Reddit replies were really asking When the thread fills with "but what about backups, rollback, HA, observability?", the commenters are pointing at the arc — they just don't always name it. The arc clarifies the order. Backups before everything. Rollback before HA. Observability before scaling. HA only after the rest. Each stage's investment is bounded; each stage protects the work of the next. A solo builder running on two VPSes doesn't have to do this overnight. A week per stage gets you through the arc in a month. That month is what turns the cheap-VPS architecture from "impressive on Reddit" to "defensible in front of customers." ## The summary The two-VPS Compose-and-WireGuard architecture is a fine starting point. The maturity arc — backups, rollback, observability, drills, then HA — is what carries it into production. Do the arc in order; do not skip stages. The replies in those Reddit threads are usually pointing at the next stage in the arc you haven't done yet. --- ## Docker health checks: sane defaults for every self-hosted service Source: https://deploytovps.com/blog/guide/docker-health-checks-sane-defaults-for-self-hosted-services Published: 2026-05-26 Tags: docker, health-checks, monitoring, self-hosted, containers, devops Every new container gets the same ritual: write a health check, wire up an alert, test it, forget about it. Here are copy-paste health checks for the services you actually run. You deploy Postgres. You deploy Redis. You deploy n8n, Plausible, maybe Uptime Kuma to monitor the rest. Each one needs a health check. Each health check is slightly different. Each one you write from scratch because the last one was three months ago and you don't remember the flags. This is the monitoring tax on self-hosted infrastructure. It's not hard. It's just tedious enough that most people skip it and rely on "I'll notice when it's down." Here are ready-to-paste Docker health checks for the services that show up in every self-hosted stack. ## How Docker health checks work Docker has a built-in `HEALTHCHECK` instruction. It runs a command inside the container on a schedule. If the command exits 0, the container is healthy. If it exits non-zero three times in a row (by default), the container is marked unhealthy. ```dockerfile HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ CMD curl -f http://localhost:8080/health || exit 1 ``` Or in `docker-compose.yml`: ```yaml services: myapp: image: myapp:latest healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 10s ``` The four knobs: - **interval**: how often to check (30s is sane for most services) - **timeout**: how long to wait before declaring the check failed (5s catches hung processes) - **retries**: how many consecutive failures before marking unhealthy (3 prevents flap) - **start_period**: grace period after container start (gives the service time to boot) ## The checks ### PostgreSQL ```yaml healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s timeout: 5s retries: 3 start_period: 15s ``` `pg_isready` is built into the Postgres image. It checks whether the server is accepting connections. No need to install curl or write a query. For a deeper check that verifies the database is actually responding to queries: ```yaml healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres && psql -U postgres -c 'SELECT 1' > /dev/null 2>&1"] interval: 30s timeout: 10s retries: 3 start_period: 20s ``` ### Redis ```yaml healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s timeout: 5s retries: 3 start_period: 5s ``` Redis responds to `PING` with `PONG`. If it doesn't, something is wrong. Redis starts fast, so the start period can be short. If you use Redis with authentication: ```yaml healthcheck: test: ["CMD-SHELL", "redis-cli -a $REDIS_PASSWORD ping | grep PONG"] interval: 30s timeout: 5s retries: 3 start_period: 5s ``` ### Nginx / reverse proxy ```yaml healthcheck: test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 5s ``` If your nginx doesn't have curl installed (the alpine image doesn't), use wget: ```yaml healthcheck: test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost/ || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 5s ``` ### Node.js apps (Next.js, Express, n8n) ```yaml healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 30s ``` Node apps take longer to start, especially Next.js with its build cache. Set `start_period` to at least 30 seconds. If your app doesn't have a `/api/health` endpoint, check the root: ```yaml test: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"] ``` The catch: checking `/` on a Next.js app triggers a full page render. For a lighter check, add a dedicated health route that returns a 200 with no rendering. ### MinIO ```yaml healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 15s ``` MinIO has a built-in health endpoint. Use `/minio/health/live` for liveness, `/minio/health/cluster` if you're running distributed mode. ### MariaDB / MySQL ```yaml healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$MYSQL_ROOT_PASSWORD"] interval: 30s timeout: 5s retries: 3 start_period: 20s ``` `mysqladmin ping` checks whether the server is alive. For a deeper check: ```yaml test: ["CMD-SHELL", "mysql -u root -p$MYSQL_ROOT_PASSWORD -e 'SELECT 1' > /dev/null 2>&1"] ``` ### MongoDB ```yaml healthcheck: test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1}).ok' --quiet | grep 1"] interval: 30s timeout: 10s retries: 3 start_period: 20s ``` Older images use `mongo` instead of `mongosh`. Check your image version. ### Traefik ```yaml healthcheck: test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/ping || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 10s ``` Requires the ping endpoint enabled in Traefik's config: ```yaml # traefik.yml ping: entryPoint: traefik ``` ## Wiring health checks to alerts Docker health checks mark containers as unhealthy, but they don't send you a notification. You need something watching the health status. **Option 1: Docker events + a script** ```bash docker events --filter event=health_status | while read event; do echo "$event" | grep unhealthy && \ curl -X POST "https://your-webhook.com" -d "Container unhealthy: $event" done ``` Crude, but works for a single-server setup. **Option 2: Uptime Kuma with Docker socket** Uptime Kuma can monitor Docker containers directly via the Docker socket. Add a "Docker Host" monitor type, point it at your container, and it alerts on unhealthy status. **Option 3: Autoheal** The `willfarrell/autoheal` container watches for unhealthy containers and restarts them automatically: ```yaml services: autoheal: image: willfarrell/autoheal restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock environment: AUTOHEAL_CONTAINER_LABEL: all ``` This restarts any unhealthy container. For selective healing, set `AUTOHEAL_CONTAINER_LABEL=autoheal` and add `labels: ["autoheal=true"]` to the containers you want auto-restarted. ## Common mistakes **Using curl in images that don't have it.** Alpine-based images often ship without curl. Use `wget --spider` or install curl in your Dockerfile. Don't skip the health check because the tool isn't there. **Setting timeout too low.** A 1-second timeout on a database that's under load will false-alarm constantly. 5 seconds is the floor for most services, 10 for databases under write pressure. **Forgetting start_period.** Without it, Docker starts checking immediately. Your service hasn't finished booting, fails three checks, and gets marked unhealthy before it ever had a chance. **Checking the wrong port.** Your app listens on 3000 inside the container, but you mapped it to 8080 on the host. Health checks run inside the container — use the internal port. **Over-checking.** A 5-second interval on a service that takes 2 seconds to respond means you're spending 40% of your health check budget on checking health. 30 seconds is fine for almost everything. Critical services can go to 10 seconds. ## The template For any new service you deploy, start with this and adjust: ```yaml healthcheck: test: ["CMD-SHELL", " || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 15s ``` Replace the test command. Adjust start_period based on how long the service takes to boot. Leave everything else alone unless you have a reason to change it. The goal isn't perfect monitoring. It's having any monitoring at all. A 30-second health check that restarts a crashed container is infinitely better than finding out your database has been down since Tuesday. --- ## Maintenance windows done right: mute the noise, not the real outages Source: https://deploytovps.com/blog/guide/maintenance-windows-mute-noise-not-real-outages Published: 2026-05-26 Tags: monitoring, maintenance-window, alerts, self-hosted, uptime-kuma, devops Planned upgrades shouldn't spam your alerts. But broad muting hides real breakage. Here's how to set maintenance windows that suppress expected noise while keeping genuine incidents visible. You schedule a database upgrade for Saturday at 2 AM. You know it'll take the app down for three minutes. You also know your monitoring will fire every alert it has: database unreachable, app health check failed, SSL endpoint timeout, upstream 502. So you mute everything. The upgrade finishes, you unmute, and you go back to bed. Except this time, the upgrade finished but the connection pool didn't recover. The app came back with a degraded state that your health check doesn't catch. By the time someone notices on Monday morning, you've had 30 hours of silent partial outage. This is the maintenance window trap: muting too broadly hides real problems that happen during or right after planned work. ## The two mistakes **Mistake 1: Not muting at all.** Your phone goes off twelve times during a planned three-minute restart. You start ignoring alerts. Alert fatigue sets in. Two weeks later, a real alert fires at 3 AM and you sleep through it because you've been conditioned to ignore nighttime notifications. **Mistake 2: Muting everything.** You suppress all alerts for a two-hour window. The planned work takes twenty minutes. For the remaining hundred minutes, your infrastructure is unmonitored. If something unrelated breaks during that window — a different service runs out of disk, a certificate expires, a deploy on another app fails — you won't know until the window closes. Both mistakes have the same root cause: treating maintenance windows as binary. Either everything is monitored or nothing is. ## What to mute and what to keep The principle: mute the alerts you expect to fire, keep everything else active. For a database restart, you expect: - Database connection checks to fail - App health checks that depend on the database to fail - Upstream proxy checks to return 502 You don't expect: - Disk space alerts on any server - SSL certificate expiry warnings - CPU or memory alerts on unrelated services - Health checks for services that don't depend on the database The maintenance window should silence the first group and leave the second group untouched. ## Implementation patterns ### Pattern 1: Tag-based muting Label your alerts with the service they depend on: ```yaml # In your monitoring config alerts: - name: app-health tags: [app, database-dependent] - name: db-connection tags: [database] - name: disk-space tags: [infrastructure] - name: ssl-expiry tags: [infrastructure] ``` When you schedule database maintenance, mute alerts tagged `database` and `database-dependent`. Everything else stays active. This works in Uptime Kuma (using tags), Grafana (using silence matchers), Prometheus Alertmanager (using matchers), and most monitoring tools. ### Pattern 2: Dependency-aware muting (Alertmanager) ```yaml # alertmanager.yml silence via amtool amtool silence add \ --alertname="DatabaseDown|AppHealth" \ --comment="Planned DB upgrade" \ --duration="30m" \ --author="ops-team" ``` The key: use `--duration` that's slightly longer than your expected maintenance, not a huge blanket window. If the upgrade should take 10 minutes, set a 30-minute silence. If it's still muted after 30 minutes, something went wrong and you want to know about it. ### Pattern 3: Two-phase window Split your maintenance into two phases: **Phase 1: Active maintenance** (muted) Silence expected alerts. Do the work. Verify the service is back. **Phase 2: Post-maintenance watch** (fully monitored) Immediately unmute everything. Watch for five to ten minutes. Confirm all health checks pass. Only then walk away. The post-maintenance watch is what most teams skip. They finish the work, see the service respond to one request, declare victory, and go to bed. The connection pool exhaustion, the slow memory leak from a config change, the replication lag — those show up ten minutes later when nobody is watching. ### Pattern 4: Maintenance health check override Instead of muting alerts, change what the health check accepts during maintenance: ```yaml # Before maintenance healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:3000/health"] # During maintenance - accept 503 (service unavailable) as "expected" healthcheck: test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/health | grep -E '200|503'"] ``` This way the container stays "healthy" during planned downtime but would still catch unexpected errors (500, connection refused, timeout). This is more surgical than muting the entire alert. ## Uptime Kuma specifics Uptime Kuma has built-in maintenance window support: 1. Go to **Settings → Maintenance** 2. Create a maintenance window with start/end time 3. Assign specific monitors to the window The monitors you assign will be paused during the window. Unassigned monitors keep running. This is the tag-based pattern built into the UI. The trap: don't assign "all monitors" to the maintenance window. Pick only the ones directly affected by the planned work. ## Grafana / Prometheus specifics In Grafana, use **Silences** in Alertmanager: 1. Go to Alerting → Silences → New Silence 2. Add matchers: `service=database`, `dependency=database` 3. Set duration to your maintenance window plus buffer 4. Add a comment explaining what's being maintained The comment matters. When someone sees a silence and wonders why alerts aren't firing, the comment tells them it's planned, not a misconfiguration. ## The checklist Before every maintenance window: - [ ] Identify which services are directly affected - [ ] Identify which alerts depend on those services - [ ] Mute only those specific alerts - [ ] Set the mute duration to planned-work-time plus a buffer (not hours longer) - [ ] Verify unrelated alerts are still active - [ ] After the work: unmute immediately, don't wait for the window to expire - [ ] Post-maintenance watch: stay for 5-10 minutes with full monitoring active - [ ] Next morning: check for any anomalies in the affected services ## The mindset shift Maintenance windows aren't about turning off monitoring. They're about telling your monitoring system what to expect. A well-configured maintenance window is a contract: "I expect these specific things to break for this specific duration. Alert me about everything else." That's the difference between controlled downtime and flying blind. --- ## PaaS to VPS migration checklist: what Vercel and Railway handled for you Source: https://deploytovps.com/blog/guide/paas-to-vps-migration-checklist-what-vercel-railway-handled-for-you Published: 2026-05-25 Tags: migration, paas-to-vps, dns, ssl, rollback, self-hosted, checklist Moving off managed PaaS feels liberating until you realize DNS, SSL, env secrets, deploy rollback, and log routing were all invisible. A step-by-step checklist so nothing falls through the cracks. You've hit the bill. Or the cold start. Or the vendor lock that makes multi-region a pricing negotiation. Whatever the trigger, you're moving off Vercel, Railway, Render, or Fly to a VPS you control. The first deploy works. The second week is where things break — not because the VPS is hard, but because the PaaS was hiding thirty things you didn't know you depended on. This is the checklist for the things PaaS platforms handle silently that you now own. ## Before you touch the VPS ### 1. Inventory what the PaaS does for you Open your PaaS dashboard and write down every service it provides. Not what you think it provides — what it actually does. Check: - **DNS management.** Does it manage your DNS records, or just point to a CNAME it gave you? - **SSL certificates.** Auto-provisioned? Auto-renewed? What domain coverage — apex, www, subdomains? - **Environment variables.** Where are they stored? Are any of them platform-specific (like `VERCEL_URL` or `RAILWAY_STATIC_URL`)? - **Build pipeline.** What runs on deploy? `npm run build`? Docker build? What base image? - **Deploy rollback.** Can you roll back to a previous deploy? How far back? Is it instant or a rebuild? - **Log routing.** Where do stdout and stderr go? Is there a log viewer? Log retention? - **Health checks.** Does the platform ping your app? What happens when it fails? - **Scaling.** Auto-scale? Fixed instances? Sleep on idle? Write this down. This is your migration scope. Every item on this list is something you need to replace, skip deliberately, or accept you're losing. ### 2. Export your environment variables Every PaaS stores env vars differently. Export them now, before you start the migration. ```bash # Railway railway variables --json > env-export.json # Vercel vercel env pull .env.production # Render # No CLI export — copy from the dashboard manually ``` Scan for platform-specific variables. `PORT` is usually fine. `RAILWAY_STATIC_URL`, `VERCEL_URL`, `RENDER_INTERNAL_HOSTNAME` — these need replacements. ### 3. Check your DNS situation If your domain's DNS is managed by the PaaS (Vercel DNS, for example), you need to move DNS management first. This is the step people skip and then wonder why their site goes down for four hours. - Move DNS to Cloudflare, Route53, or your registrar's DNS. - Set TTL to 300 seconds (5 minutes) at least 24 hours before the migration. - Write down every DNS record the PaaS created for you. ## The VPS setup checklist ### 4. Base server hardening Before deploying your app: ```bash # Create a non-root user adduser deploy usermod -aG sudo deploy # SSH key auth only cp -r ~/.ssh /home/deploy/.ssh chown -R deploy:deploy /home/deploy/.ssh sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config systemctl restart sshd # Firewall ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw enable # Automatic security updates apt install unattended-upgrades -y dpkg-reconfigure -plow unattended-upgrades ``` PaaS platforms do all of this invisibly. On a VPS, skipping this means your server is one brute-force attack away from a bad day. ### 5. SSL certificates The PaaS auto-provisioned and auto-renewed your certs. Now you need: ```bash # Install certbot apt install certbot python3-certbot-nginx -y # Get certificates certbot --nginx -d yourdomain.com -d www.yourdomain.com # Verify auto-renewal certbot renew --dry-run ``` The trap: certbot's auto-renewal runs via systemd timer, but nginx doesn't reload automatically after renewal. Add a deploy hook: ```bash # /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh #!/bin/bash nginx -t && systemctl reload nginx ``` Without this, your cert renews but nginx keeps serving the old one until the next restart. ### 6. Reverse proxy Your app runs on port 3000. The internet expects port 443. nginx bridges the gap: ```nginx server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Test with `nginx -t` before reloading. A syntax error in the config takes down every site on the server, not just yours. ### 7. Process management PaaS platforms restart your app when it crashes. On a VPS, you need PM2 or systemd: ```bash # PM2 npm install -g pm2 pm2 start npm --name "myapp" -- start pm2 save pm2 startup ``` The `pm2 startup` command generates a systemd service so PM2 itself restarts after a server reboot. Without it, a reboot means your app stays down until you SSH in and notice. ### 8. Environment variables Create a `.env` file on the server (not in the repo): ```bash # /home/deploy/myapp/.env NODE_ENV=production PORT=3000 DATABASE_URL=postgresql://... # ... rest of your exported vars, minus the platform-specific ones ``` Permissions matter: ```bash chmod 600 .env chown deploy:deploy .env ``` Don't commit `.env` to git. Don't put it in a world-readable location. PaaS platforms encrypted these at rest — your plaintext file on disk is now the weakest link in your security chain. ### 9. Deploy pipeline The PaaS deployed on git push. You need to replace that: **Option A: Git pull + PM2 restart** ```bash # deploy.sh #!/bin/bash set -e cd /home/deploy/myapp git pull origin main npm ci --production npm run build pm2 restart myapp ``` **Option B: GitHub Actions + SSH** Automate it so `git push` still triggers a deploy, but through your own pipeline. The key difference from PaaS: if the build fails, you need to catch it. `set -e` in your deploy script is the minimum. Without it, a failed `npm run build` still runs `pm2 restart`, and PM2 restarts your app with stale build output. ### 10. Rollback plan PaaS rollback is one click. VPS rollback is whatever you set up: ```bash # Before deploy, tag the current state git tag pre-deploy-$(date +%Y%m%d-%H%M%S) # To rollback git checkout pre-deploy-20260525-143022 npm ci --production npm run build pm2 restart myapp ``` Or use symlinked releases (the Capistrano pattern): ``` /home/deploy/myapp/ releases/ 20260525-143022/ 20260525-160000/ current -> releases/20260525-160000/ ``` Rollback becomes changing a symlink and restarting PM2. No rebuild, no npm install, instant. ### 11. Logging PaaS log viewers are gone. Replace them: ```bash # PM2 logs pm2 logs myapp --lines 100 # Or use journalctl if running as a systemd service journalctl -u myapp -f ``` For log retention, PM2's `pm2-logrotate` module prevents your disk from filling up: ```bash pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 7 ``` ### 12. Health checks and uptime monitoring The PaaS pinged your app and restarted it on failure. Set up your own: - **PM2 watches** the process and restarts on crash (built-in). - **External monitoring** (Uptime Kuma, self-hosted; or BetterStack, UptimeRobot) pings your public URL and alerts you when it's down. Don't skip external monitoring. PM2 restarts your app, but it can't tell you when nginx is misconfigured, when your SSL cert expired, or when your server ran out of disk. ## After the migration ### 13. Verify everything Run through this checklist after your first deploy: - [ ] App responds on HTTPS (not just HTTP) - [ ] SSL cert covers all expected domains - [ ] Environment variables are loaded correctly - [ ] App restarts automatically after a crash (`pm2 restart` test) - [ ] App survives a server reboot - [ ] Deploy script works end-to-end - [ ] Rollback procedure works - [ ] Logs are accessible and rotating - [ ] DNS has propagated (check from multiple locations) - [ ] Old PaaS deployment is stopped (not still serving traffic) ### 14. Cancel the PaaS Don't cancel immediately. Keep the PaaS deployment running (but stopped) for at least a week. If something goes wrong on the VPS, you can point DNS back in five minutes instead of rebuilding from scratch. ## What you gain, what you lose **You gain:** predictable costs, full control, no cold starts, no vendor lock-in, SSH access, the ability to run anything. **You lose:** zero-config deploys, automatic scaling, the PaaS team's on-call rotation, and the luxury of not thinking about servers. That trade is worth it for most apps that have outgrown the free tier. But only if you replace what the PaaS was doing — not just the hosting, but the thirty invisible services around it. --- ## Self-Hosted n8n: The Production-Readiness Checklist the Install Guide Skips Source: https://deploytovps.com/blog/guide/n8n-self-host-production-readiness-checklist Published: 2026-05-10 Tags: n8n, self-host, production-readiness, backups, monitoring, workflow-automation You followed the n8n install guide. The container is running. Production starts ten steps later — backups, restore drills, metrics, version pinning, workflow git history. The five gaps that turn a toy setup into a real one. A small operations team finishes the n8n install guide on a Sunday afternoon. Docker is up, the workflow editor is reachable at `n8n.example.com`, the first webhook fires successfully. They wire up four production workflows over the next week — Stripe events to Slack, GitHub releases to internal docs, support tickets to a triage queue, and a daily reporting summary. By the second week, n8n is running 1,200 executions a day. By the third week, somebody asks the question nobody had thought to ask: *what happens when this box goes down?* There is no backup. There is no monitoring. There is no rollback for a bad workflow update. The credentials for every connected service — Stripe, GitHub, Slack, the support tool, the database — are encrypted in n8n's local SQLite database, which lives on a single VPS, on a single disk, with no replication. Every business workflow the team automated this month is now hostage to one unattended Docker container. This is the production-readiness gap. The install guide ended at "n8n is running." Production starts about ten steps later. The good news is the steps are well-defined; the bad news is they are unglamorous and almost nobody writes them up. This is the writeup. If you have not yet stood up n8n, follow the install guide first — the [self-host n8n on a VPS](https://deploytovps.com/self-host/n8n) walkthrough gets you to the "running" state. This guide picks up from there. ![You followed the n8n install guide. The container is running. Production starts ten steps later — backups, restore drills, metrics, version ](https://assets.stoicsoft.com/posts/deploytovps/guide/n8n-self-host-production-readiness-checklist/featured.png) ## The five gaps between "running" and "production" Five categories. Address them in order; each one builds on the previous. 1. **Persistent state safety** — backups, encryption keys, restore drills. 2. **Operational visibility** — execution metrics, error alerts, queue depth. 3. **Update and rollback discipline** — version pinning, backup-before-update, rollback path. 4. **Workflow change control** — the workflows themselves are state, not just config. 5. **Failure recovery** — what happens when a workflow fails mid-execution. Each gap is roughly the same shape: there is something n8n does not do for you by default that turns out to be load-bearing once a real business depends on the box. None of the gaps are hard to close. All of them are easy to forget. ## Gap 1 — persistent state safety n8n's persistent state lives in three places by default: the SQLite (or Postgres) database, the credentials encryption key, and the file system for any binary outputs. **The database.** Holds workflow definitions, execution history, credentials (encrypted), and webhook routes. Losing it means losing every workflow you've built. By default, SQLite ships at `/home/node/.n8n/database.sqlite` inside the container. **The encryption key.** Lives at `/home/node/.n8n/config` (in `n8n_encryption_key`). Without this exact key, an old database backup is useless — credentials cannot be decrypted. Backups of the database alone are *not enough* if the key changes. **File system outputs.** Workflows that download attachments or write reports use the `/files` mount. If the box dies, those files die with it. A working backup setup: ```yaml # docker-compose.yml — relevant excerpt services: n8n: volumes: - n8n_data:/home/node/.n8n - n8n_files:/files volumes: n8n_data: n8n_files: ``` ```bash # /etc/cron.daily/n8n-backup #!/usr/bin/env bash set -euo pipefail STAMP=$(date +%Y%m%d-%H%M) DEST=/var/backups/n8n/$STAMP mkdir -p $DEST docker exec n8n sqlite3 /home/node/.n8n/database.sqlite ".backup '/home/node/.n8n/db-$STAMP.sqlite'" docker cp n8n:/home/node/.n8n/db-$STAMP.sqlite $DEST/database.sqlite docker exec n8n rm /home/node/.n8n/db-$STAMP.sqlite docker cp n8n:/home/node/.n8n/config $DEST/config # Off-site copy rclone copy $DEST hetzner:n8n-backups/$STAMP # Retention find /var/backups/n8n -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} \; ``` Note `sqlite3 .backup` — not a file copy. SQLite uses write-ahead logging; copying the file directly produces a corrupt backup half the time. The `.backup` command produces a consistent snapshot. The encryption key file (`config`) is small; copy it on every backup. **Run a restore drill.** Once a quarter, on a fresh VPS, restore from a backup and prove n8n comes up with workflows intact. A backup that has never been restored is not a backup; it is hope. The number of teams that discover this on the day they need to restore is unreasonably high. ## Gap 2 — operational visibility By default, n8n logs to stdout and that is the entirety of its observability story. For a single workflow, that is fine. For 1,200 executions a day, you need three signals: **Execution success rate.** What percentage of executions in the last hour succeeded? A drop from 99% to 92% is meaningful — somebody changed an upstream API or rotated a token. **Queue depth.** If using the queue runner mode (which you should, once you have more than ~50 executions a day), how many pending executions are waiting? A growing queue means n8n cannot keep up with incoming triggers. **Per-workflow failure count.** Which specific workflows are failing? Aggregate success rate hides the case where one workflow is failing 100% while everything else is fine. The minimum viable setup is the n8n Prometheus endpoint plus a simple Grafana dashboard. Enable it with: ``` N8N_METRICS=true N8N_METRICS_PREFIX=n8n_ ``` Scrape the `/metrics` endpoint, alert on `n8n_workflow_failed_total` rate of change, and you have caught 90% of the cases that matter. If you are not running Prometheus, even logging the per-execution result to a managed log service (BetterStack, Axiom, Loki) and a single weekly review is more than zero. Anything beats the default. ## Gap 3 — update and rollback discipline n8n releases roughly weekly. Some releases are bug fixes, some are new features, some are breaking schema changes that require a database migration. Pulling `n8nio/n8n:latest` is the path that produces the most "this used to work yesterday" incidents. A working policy: - **Pin to a specific version**, e.g. `n8nio/n8n:1.42.0`, in your compose file. - **Read the release notes** before bumping. n8n maintains a clean changelog; read the entries between your current version and the target. - **Back up before bumping.** Run the backup script manually as the first step of every update. - **Test on a staging instance** for any breaking change. A second VPS at $4/month with a copy of production workflows catches schema-incompatible upgrades. - **Bump in a maintenance window** when the queue is empty, if your workflows tolerate any pause. Most do not need this; some critical webhook flows do. The rollback path: stop the container, restore the pre-bump database backup, downgrade the image tag, restart. The encryption key from the backup matters here — do not forget to restore it alongside the database. ## Gap 4 — workflow change control The workflows themselves are state. Every change to a workflow is a change to your business logic. By default, n8n stores workflows in its database and that is it — no version history beyond what n8n's own UI tracks, which is limited. Two practices close this gap. **Export workflows to JSON, commit them to git.** A small cron job that runs `n8n export:workflow --all --output=/data/workflows-export.json` once a day, paired with a git commit and push to a private repo, gives you a full version history outside n8n. Reverting a bad workflow change becomes a `git revert`. **Treat the production n8n instance as deploy-only.** Editing live workflows in the n8n UI on the production box is fast but it bypasses every other safeguard. Build the discipline of editing in a staging instance, exporting, committing, and importing to production. It is slower for a week and a lot safer for the rest of the year. These two practices together turn workflow management from "trust the n8n UI history" to "your workflows are in git, like every other piece of your codebase." ## Gap 5 — failure recovery When an execution fails mid-run, n8n's default behavior is to mark it failed and stop. That is correct for most workflows. For a few specific shapes, it is not enough. **Idempotent workflows that can be safely retried.** A "Stripe webhook → record in database" workflow that fails because the database was momentarily unreachable should retry with exponential backoff. Configure this at the workflow level (each node has retry settings) rather than relying on the upstream caller to retry. **Workflows with side effects that cannot be naively retried.** A "send email when order ships" workflow that fails after sending the email but before recording it must not retry the email send. The fix is to design the workflow so the side-effect step is the *last* step (so a retry from before it is safe) or to mark which executions completed which side effects in your own database. **Long-running workflows that hit a timeout.** Default n8n execution timeout is 5 minutes. Workflows that legitimately take longer (large data exports, batch enrichment) need an explicit `executionTimeout` override. Without it, they fail silently halfway through and produce inconsistent state. Spend the time once to classify each of your workflows into one of these three buckets. The classification is one column in a spreadsheet per workflow. The discipline is in checking that column when you write the next workflow. ## A 30-minute production readiness pass Most of what's above can be installed in one afternoon. A working ordering for a team that already has n8n running and is reading this guide: 1. Volume mount the data directory if not already (5 min). 2. Add the daily backup cron with off-site copy (10 min). 3. Enable metrics and point Prometheus at it (5 min, assuming Prometheus is already running). 4. Pin the n8n image to its current version in compose (1 min). 5. Schedule a quarterly restore drill on the calendar (1 min). 6. Set up the daily JSON export of workflows to git (10 min). That is the production-readiness pass. It is not exotic, it is not hard. It is the difference between an n8n instance that survives the next disk failure and one that does not. The pattern under all five gaps is the same: n8n's defaults optimize for "you are exploring the tool" rather than "you are running it for a business." That is the right default for the install experience. It becomes the wrong default the moment a real workflow starts running every day. Closing the gap is one afternoon of setup. The team that does it is the team that does not have a Sunday-night incident in three months. --- **Related in the StoicSoft network** If you run monitoring, uptime checks, or alerting across self-hosted apps like the ones above, [ServerCompass](https://servercompass.app) is the StoicSoft network's tool for wiring tiered severity, flap suppression, and low-noise alerts into a single dashboard. --- ## Shared Hosting to VPS Migration: The Rollback Plan Most Guides Skip Source: https://deploytovps.com/blog/guide/shared-hosting-to-vps-migration-rollback-plan Published: 2026-05-10 Tags: vps-migration, rollback, cutover, dns, database-replication, shared-hosting You can read every VPS setup guide and still not migrate, because nobody answers the real blocker: if it breaks at 9 PM Saturday, can you put it back? The seven-step cutover where every step has a known rollback. A small SaaS team has been running on $20-a-month shared hosting for three years. Traffic has grown. Every Friday afternoon, the database starts queueing connections. Every Monday morning, support tickets pile up about pages that took eleven seconds to load. They have read the VPS guides, priced out a $14 Hetzner box that would handily outperform their current host, and bought the domain extension. They have not migrated. They will not migrate, until they answer one question that none of the guides address head-on: If we cut over and something breaks at 9 PM on a Saturday, can we put it back? The migration guides describe how to set up the new server. They walk through Docker, nginx, Let's Encrypt, the database import. They are correct. They are also useless to a team that cannot afford a 30-minute outage. The blocker is not the technical work. The blocker is the rollback story, and almost nobody documents it because it is unglamorous to write. This guide is the rollback story. It explains the cutover sequence in the order that protects you, the exact moment a rollback is and is not safe, and the three states the migration is in at any given moment. The point is not to get the migration done faster. The point is to have a clean, well-rehearsed way to undo it. ![You can read every VPS setup guide and still not migrate, because nobody answers the real blocker: if it breaks at 9 PM Saturday, can you pu](https://assets.stoicsoft.com/posts/deploytovps/guide/shared-hosting-to-vps-migration-rollback-plan/featured.png) ## Why "rollback fear" is the right name for this Most migration guides assume the user is migrating because something is on fire and they need a fix today. That is not the typical case. The typical case is a team that has been on shared hosting for years, has acceptable performance most of the time, and is migrating because the next year of growth will not fit. They are not in pain right now. They are choosing to migrate. That changes the risk calculus completely. A team in pain accepts a 30-minute outage to escape a worse-than-30-minute current state. A team migrating proactively does not. Their current state is "everything works, occasionally slow." A 30-minute outage during the migration is *worse* than the status quo, even if the post-migration state is better. Anything more than a 30-minute outage starts to look like a multi-hour outage in customer perception, and now the migration was a mistake. The mental model the guides skip: every step of the migration is a *temporary state*, and you must be able to bounce back from any of them to the original shared host within minutes. Not eventually. Within minutes. ## The three states of any migration At any moment during the cutover, the system is in exactly one of these states. Name them so you know where you are. **State A — only shared host serves traffic.** The starting state. The new VPS may exist, may have the app deployed and tested, but no public DNS points to it. Production traffic flows entirely to the original host. **State B — both hosts can serve traffic, DNS is split.** The intermediate state. DNS records point to the new VPS for some users (those whose resolvers picked up the new value) and the old host for others (whose resolvers are still in the cached TTL window). Both hosts must be able to serve a real request and read/write the same data. **State C — only new VPS serves traffic.** The end state. Old host is decommissioned, DNS has fully propagated, all traffic flows to the new VPS. State B is the dangerous one. It is also the state every DNS-based migration spends time in, sometimes hours, sometimes a day. The migration is safe only if both hosts can handle real traffic during state B without producing inconsistent results. The standard mistake is to treat state B as "almost done" and stop being careful. It is not almost done. It is the most fragile point in the migration. ## The cutover sequence that protects rollback at every step There are seven steps, and the order matters. Each step has a known rollback. Doing the steps in any other order leaves windows where rollback requires data reconciliation, which is the thing you are trying to avoid. ### 1. Lower the TTL on your DNS records (24 hours before) Set the TTL on every record you will change to 60 or 300 seconds, **a full day before** you intend to cut over. DNS resolvers cache the old TTL until it expires. If your TTL is currently 86400 (the default in many providers), and you change the record at noon, resolvers can still send users to the old IP until the next noon. You cannot rollback through that window. By lowering TTL a day in advance, you ensure that when you change records the next day, propagation is measured in minutes, not in hours. **Rollback at this step:** none needed. Lowering TTL is reversible at any time and changes no behavior. ### 2. Set up the new VPS to its full final state (no public DNS) Stand up the new VPS completely. Install the app, the database, the proxy, the certificates, the backup cron. Test it end-to-end using a hosts file entry on your laptop that pretends production DNS already points there. Run synthetic traffic. Confirm the app works the way it does on the old host. This step takes the longest. It is also the cheapest to mess up: nobody depends on it yet. **Rollback at this step:** delete the VPS. No production impact. ### 3. Replicate the database from old to new, ongoing This is the load-bearing step. State B requires that both hosts read consistent data. The way to get that is replication: writes that happen on the old host show up on the new host within seconds. The two databases are slaves of one truth. Three setups, in order of preference: - **Logical replication** (Postgres or MySQL native). The new host subscribes to the old host's write stream. Both can serve reads; the old host serves all writes. This is the cleanest pattern. - **Trigger-based replication** if logical isn't available on shared hosting. The old host writes a row, a trigger writes the same row to a queue table, a small bridge service ferries the rows to the new host. Slightly slower, fully workable. - **Application-level dual-write.** The application, deployed to both hosts, writes every change to both databases. This is the last resort. It is brittle and produces drift if either host is unreachable. **Rollback at this step:** stop replication. Both databases now drift, but the old host is still authoritative for production traffic. No user impact. ### 4. Switch the new VPS to read-only mode against its own database Configure the new VPS so the application can read from its local database but cannot write. This is a 5-minute change at the application config level — flip a `READ_ONLY=true` env var, redeploy. The app on the new host now serves any request that does not modify state. This is what makes state B safe. While DNS is split, both hosts serve reads from their respective databases (which are kept in sync by replication). Only the old host serves writes. There is exactly one writer; there is no conflict. **Rollback at this step:** flip the env var back. The new host returns to its previous state. No production impact. ### 5. Cut DNS over to the new VPS This is the moment the migration "happens" from a user perspective. Update the A or AAAA record(s) for your domain to point to the new VPS. Because TTL was lowered in step 1, propagation completes within 5–15 minutes for most resolvers. Some users hit the new VPS immediately. Some users continue hitting the old host for the duration of the propagation window. Both hosts respond correctly because of step 4. **Rollback at this step:** point DNS back at the old host. Within 5–15 minutes, traffic returns to the old host. No data was changed on the new host because of step 4. ### 6. Promote the new VPS to read-write, demote the old host to read-only After DNS has propagated to nearly all resolvers (give it an hour to be safe), reverse the read-only flag. Set the new VPS to writable. Set the old host to read-only. Stop the replication direction so the old host is no longer being written to. For roughly five minutes during this step, both hosts may briefly accept writes if a stray resolver still routes to the old host. This is the only window in the migration where conflict is theoretically possible. In practice, the volume of traffic to the old host at this point is so low that conflict almost never happens, and a brief read-only period on the old host before the swap eliminates it entirely. **Rollback at this step:** flip the read-only flags back, point DNS at the old host. The migration unwinds, but you must reconcile any writes the new VPS accepted during step 6 against the old host's database. This is the first step where rollback is no longer "instant" — it is "ten minutes of careful reconciliation." This is the step the migration narrows to. Everything before it is freely reversible. Everything after it requires reconciliation. ### 7. Decommission the old host Wait at least 48 hours. Watch logs on both hosts. If the old host gets zero requests for that full window, you know DNS has fully propagated. Now you can shut down the old shared hosting plan. **Rollback at this step:** complicated. The old shared hosting plan is gone. To "rollback" past this point means standing up a new instance somewhere, restoring from a backup, and re-pointing DNS. It is doable, but it is a real outage. After 48 hours, you should not need it. ## The rollback test Before any of the seven steps run, do a dry run of the rollback. Pick state B (the most dangerous one) and answer concretely: 1. What command points DNS back at the old host? 2. How long, after running it, until 95% of users are back on the old host? 3. What state is the old host's database in? Is replication still flowing? Did it lag? 4. If the new VPS accepted writes during the failure (it shouldn't have, because of step 4), how do those get reconciled? If you cannot answer these in writing before the migration starts, you are not ready to migrate. The dry run is the migration plan. The rest is execution. ## What the standard guides are missing A standard "deploy your app to a VPS" guide ends at step 2 of the seven above. The guide is correct that step 2 produces a working VPS. It is silent on the question that actually blocks teams: *can I undo this safely if it goes wrong?* The answer the seven steps give is: yes, at every step before step 6, the rollback is a single command and takes minutes. At step 6, the rollback requires light reconciliation. After step 7, the migration is committed and rollback means rebuilding from a backup. That answer is what makes the migration tractable for a team that is not in pain. They are not migrating to escape an emergency. They are migrating to give themselves headroom for the next two years of growth. They will only do it if the path back is as clearly written as the path forward. The seven steps and their rollbacks are that path. The boring infrastructure rule, which is true of every cutover and not just hosting migrations: every step of a migration is a temporary state. The migration is only as safe as the worst rollback at the worst-state step. Spend the time to make every step's rollback small. Then move forward, knowing you can move back. --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## Docker Compose Drift: How to Catch Dev/Prod Differences Before They Break Your Deploy Source: https://deploytovps.com/blog/guide/docker-compose-drift-preflight Published: 2026-05-10 Tags: docker-compose, config-drift, vps-deploy, preflight, dev-prod-parity Most "works on staging, broken on prod" deploys are docker-compose drift — a service name, env var, or network alias that diverged silently across files. A 30-line preflight catches nine in ten before the deploy runs. A team ships a feature on Friday afternoon. Staging is green. The deploy runs against prod. Forty seconds later, every request to `/api/orders` returns a 502. Logs say nginx cannot resolve the upstream `orders-svc` by name. The incident channel lights up. Six minutes of `docker compose ps` later, somebody finds the cause: in `docker-compose.prod.yml`, the orders service is named `orders` (without the `-svc` suffix). The dev compose file uses `orders-svc`. The nginx config — copied straight from staging — references `orders-svc`. Staging worked because staging's compose matched. Prod broke because prod's didn't. Nothing about the service was wrong. The service-name string drifted across compose files, and the deploy was the moment that drift became visible. This is "compose drift," and it is the single most common cause of "works on dev, broken on prod" deploy incidents on small VPS setups. It is also one of the easiest classes of failure to catch before the deploy runs. A 30-line preflight script applied at the start of every deploy catches roughly nine out of ten of these incidents, and turns the remaining one into a fast diagnosis instead of a slow one. This guide walks the four shapes drift takes, the specific preflight that catches each shape, and how to wire it into your deploy without slowing things down. ## What "drift" actually means here Most teams have at least three compose files that describe the same stack: a base file, a `docker-compose.dev.yml` overlay, and a `docker-compose.prod.yml` overlay. Sometimes there is a fourth for staging. The overlays exist because dev needs hot-reload mounts and exposed ports while prod needs internal networks and resource limits. That separation is correct and necessary. The problem is that the overlays are edited independently. A new env var goes into `dev.yml` because the engineer added it during local work. The same env var does not make it into `prod.yml` because the deploy ran on a different day, on a different machine, and nobody crossed-checked. Six weeks later the field is load-bearing in prod and nobody remembers why staging works. Drift is what happens when files that should describe the same logical stack stop describing the same logical stack. The deploy is when the divergence pays its bill. ## The four shapes of compose drift Almost every drift incident is one of these four. Knowing the shapes is half the diagnosis. ### 1. Env var drift A variable is set in one compose file but missing or differently-named in another. The app reads it at startup, sees `undefined`, and either crashes immediately or — worse — silently falls back to a default that points at the wrong database. Common variants: - `STRIPE_KEY` in dev, `STRIPE_API_KEY` in prod - `DATABASE_URL` set in `dev.yml` and read from `.env`, but in prod set via shell environment that is not exported into the compose context - A new feature flag that defaults to `true` in dev and `false` in prod because nobody updated `prod.yml` ### 2. Volume path drift A bind-mount in dev (`./data:/app/data`) does not exist in prod, or points to a different host path. The app starts, but writes to a path that vanishes when the container restarts. The painful version: the dev path is bind-mounted, the prod path is a named volume, and the app's behavior depends on which one it has. Dev and prod each work in isolation but produce subtly different output formats. ### 3. Network alias / service name drift The error from the opening of this article. Service names — the strings other services use as DNS hostnames inside the compose network — differ between files. nginx upstreams, app config, and inter-service calls all assume one name. The compose file in front of you assumes another. Variants: a service is renamed in dev and not in prod; an alias is added in `prod.yml` (`aliases: ["orders-svc"]`) but absent in `dev.yml`, so dev code that uses `orders-svc` works in prod but not dev (or vice versa). ### 4. Restart policy and `depends_on` drift `restart: unless-stopped` in prod, no restart policy in dev. `depends_on` includes a healthcheck condition in dev but not prod. The result is a stack that starts cleanly in dev (where the engineer waits ten seconds before testing) but races on prod where the database is still initializing when the API tries to connect. This is the subtlest of the four. Nothing is wrong; the timing is wrong. It usually surfaces as "the deploy works most of the time." ## Why teams don't catch this Three structural reasons. **The diff is small.** A drift bug usually fits inside a single line. `docker-compose.prod.yml` has `orders` instead of `orders-svc`. Code review reads "renamed service" and approves. The reviewer cannot easily check whether the rename was applied to all dependents, because the dependents are in nginx config, application code, and a different compose file. **Local tests don't see it.** The dev compose works on the dev machine. The CI compose works in CI. Each environment validates its own file in isolation, never against another. A test suite that runs `docker compose up` and pings `localhost:3000` passes regardless of whether `prod.yml` is consistent with `dev.yml`. **The deploy is the first time both files are loaded together.** And by then the deploy is already running. The fix is to load both files together earlier — specifically, to *resolve* both files into their merged form, then compare the resolved configurations against each other before any container starts. ## The preflight, as a script `docker compose config` is the load-bearing tool. It reads the base file plus any overlays, resolves all variable substitutions, and prints the final merged compose specification as YAML. Run it once for dev, once for prod, and you have two canonicalized stack descriptions you can diff. A working preflight, ~30 lines: ```bash #!/usr/bin/env bash set -euo pipefail PROD_FILES=(-f docker-compose.yml -f docker-compose.prod.yml) DEV_FILES=(-f docker-compose.yml -f docker-compose.dev.yml) PROD=$(docker compose "${PROD_FILES[@]}" config 2>/dev/null) DEV=$(docker compose "${DEV_FILES[@]}" config 2>/dev/null) # 1. Service names must match exactly prod_services=$(echo "$PROD" | yq '.services | keys | .[]' | sort) dev_services=$(echo "$DEV" | yq '.services | keys | .[]' | sort) if [[ "$prod_services" != "$dev_services" ]]; then echo "DRIFT: service names differ" diff <(echo "$prod_services") <(echo "$dev_services") || true exit 1 fi # 2. Required env var keys must appear in both (values may differ) for svc in $prod_services; do prod_keys=$(echo "$PROD" | yq ".services.$svc.environment | keys | .[]" 2>/dev/null | sort || true) dev_keys=$(echo "$DEV" | yq ".services.$svc.environment | keys | .[]" 2>/dev/null | sort || true) if [[ "$prod_keys" != "$dev_keys" ]]; then echo "DRIFT: env var keys differ for service: $svc" diff <(echo "$prod_keys") <(echo "$dev_keys") || true exit 1 fi done # 3. Network aliases must match prod_aliases=$(echo "$PROD" | yq '.services | to_entries | map({(.key): (.value.networks // {} | to_entries | map(.value.aliases // []) | flatten)}) | .[]' | sort) dev_aliases=$(echo "$DEV" | yq '.services | to_entries | map({(.key): (.value.networks // {} | to_entries | map(.value.aliases // []) | flatten)}) | .[]' | sort) if [[ "$prod_aliases" != "$dev_aliases" ]]; then echo "DRIFT: network aliases differ" diff <(echo "$prod_aliases") <(echo "$dev_aliases") || true exit 1 fi echo "preflight ok: no drift detected" ``` The script answers four questions: do both stacks define the same services, do those services declare the same env var keys, do they share the same network aliases, and (implicitly, by exit code) is it safe to deploy. Run it before `docker compose up -d` and you have caught nine out of ten drift incidents. `yq` is the only non-standard dependency. Install it with `apt install yq` or the static binary from the project releases page. ## A worked example: catching a real drift Push a change to `dev.yml` that adds a `FEATURE_FLAGS_URL` env var, but forget to add it to `prod.yml`. Run the preflight: ``` $ ./scripts/compose-preflight.sh DRIFT: env var keys differ for service: api < FEATURE_FLAGS_URL > SENTRY_DSN > STRIPE_KEY < SENTRY_DSN < STRIPE_KEY exit 1 ``` The script blocks the deploy. The fix is one line in `prod.yml`. Total time from "preflight failed" to "preflight passes": under a minute. Compare to the failure mode where the deploy runs, the API silently picks up an `undefined` flag URL, and a different team discovers it three hours later when the experiments dashboard is empty. ## What the preflight cannot catch Be honest about the limits. The preflight is structural — it compares shapes. Three classes of bug live below it. - **Image tag mismatches.** Both files declare a service `api`, but one points at `myapp:1.4.2` and the other at `myapp:latest`. The preflight passes; the runtime behavior diverges. Adding a "image tags must match" check to the preflight is one more `yq` line, but in practice most teams want different tags between dev and prod (`:dev` and `:prod`). Decide your policy and codify it. - **Host kernel and Docker version drift.** The compose files match. The host running them does not. A volume mount option that works on Ubuntu 22 fails on Ubuntu 20. No file diff catches this. The mitigation is to run the same Docker version everywhere, and check it in the deploy script. - **External dependencies.** A managed Postgres in prod, a containerized Postgres in dev. The compose files are deliberately different. The preflight will flag it as drift; you will mark the difference as expected. Build an allowlist for known-acceptable divergences so the preflight does not become noise. The preflight catches a specific, common, expensive class of bug. It does not catch everything, and pretending otherwise will erode trust in it. ## CI vs deploy script Both. Run the preflight in CI on every PR that touches a compose file — that is where it stops bad changes from landing. Run it again at the start of the deploy script — that is where it catches drift introduced by a hand-edit on the VPS that never made it back into git. The two checks are not redundant; they cover different escape routes. The deploy-time check is especially important because compose files on production VPSes are surprisingly often hand-edited. An ops person fixes a port collision at 2 AM, never opens a PR, and the file in git no longer matches the file on disk. The next deploy that pulls from git will overwrite the fix and reintroduce the bug. A preflight that compares the on-disk file against the resolved compose catches the divergence before the overwrite. ## The long-term fix: one base file, thin overlays, no shared state outside compose The preflight is a backstop. The real fix is structural: keep `docker-compose.yml` as the canonical description of every service, every env var, every network, every volume. Use `dev.yml` and `prod.yml` only for the small differences — port mappings, mount paths, resource limits. Anything that appears in only one overlay is a candidate for either being moved to the base or being explicitly justified in a comment. A repo where 95% of the stack lives in the base file and 5% lives in overlays does not have drift incidents. A repo where 60% of the stack is duplicated across overlays has drift incidents every other month. Compose drift is not a Docker problem. It is a synchronization problem in two text files. Treat the synchronization as load-bearing — with a preflight in CI and at deploy time, plus a discipline of keeping overlays thin — and the class of bug stops happening. The minute you spend writing the preflight pays for itself the first time a Friday-afternoon deploy does not turn into an incident. --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## Single VPS to multi-host: the three-rung migration ladder Source: https://deploytovps.com/blog/guide/single-vps-to-multi-host-migration-ladder Published: 2026-05-09 Tags: vps-migration, database-split, horizontal-scaling, self-host-ops, postgres, load-balancer Your single VPS is at 80% RAM and the database is fighting the app for IO. Most guides jump straight to Kubernetes — but there's a pragmatic three-rung ladder, and most teams never climb past rung 2. Your single VPS sits at 80% RAM. The database is fighting the app for disk IO. You restart Postgres on a Saturday and everything's fine for two days. You know you need a second machine — but every guide jumps straight to Kubernetes, which is overkill for the next five years of your business. The honest truth is that almost every "I need to split into multiple boxes" moment is one of three rungs on a short ladder. Most teams never reach the third rung. A lot don't even need the second once they look at the numbers. The trick is knowing which rung you're on, doing only that rung's work, and stopping when the symptoms go away. This guide walks the ladder in order, with the diagnostic signals, the migration mechanics, and the failure modes that bite at each step. ![Three-rung migration ladder: vertical resize, database split, horizontal app fleet](https://assets.stoicsoft.com/posts/deploytovps/guide/single-vps-to-multi-host-migration-ladder/featured.png) ## The signal that you've outgrown one box A single metric is never a forcing function. Three together are. If your VPS is hitting all three, you have a real capacity problem and not a tuning problem. **Sustained RAM > 80% with disk swap pressure.** Run `free -m` and look at the `available` column — under 20% of total for hours, not minutes. Then `vmstat 5` for a minute. If `si` (swap-in) and `so` (swap-out) are non-zero on most rows, the kernel is paging hot memory to disk, and your latency is about to look terrible the next time the working set shifts. **Database IO contention with the app.** Run `iostat -x 5`. Watch the `await` (average IO wait in ms) and `%util` columns for your data volume. If `%util` sits north of 80% during normal traffic and `await` is over 20ms, your database and your app are queueing for the same spindle or NVMe queue. Restarting Postgres "fixes it for two days" because you've reset the page cache — not because you've fixed anything. **p99 latency degrades during DB checkpoints or backup windows.** Look at your APM or your reverse-proxy logs. If p50 stays flat but p99 doubles every time `pg_dump` runs at 03:00, your worst-case user is paying for your maintenance schedule. That's a structural problem, not a one-off. None of these alone is enough. All three together mean it's time. ## The three rungs of the ladder Name them up front so you know where you're aiming: - **Rung 1 — vertically scale the box.** Double the resources. Cheaper than your time and rarely loses you a night of sleep. Many "I need to split" moments dissolve here. - **Rung 2 — split the database off.** A second VPS that runs only Postgres or MySQL or Redis. The app stays on box 1. This is where 90% of small-team production setups land and stay. - **Rung 3 — split the app horizontally.** Two or more app instances behind a load balancer, sharing the rung-2 database. Required only when single-app-instance throughput is genuinely the bottleneck. There is a rung 4. We'll name it at the end so you know what you're choosing not to do. ## Rung 1 in detail — vertically scale the box When the bottleneck is CPU steady-state above 70% with no IO contention, or RAM above 80% but the app (not the DB) is the consumer, the answer is more box, not more boxes. A 4 vCPU / 8 GB instance costs less than two hours of your engineering time per month. You will not regret upgrading first. How to do it on most providers: take a snapshot from the console, resize the instance, boot. Hetzner, DigitalOcean, Vultr, Linode, OVH all support a console-driven resize that keeps the same IP. Downtime is typically 2 to 5 minutes during the reboot. AWS Lightsail and EC2 require stopping the instance and changing the type, also under 5 minutes. The IP usually does not change, so DNS, firewalls, and reverse-proxy configs stay put. The one trap: some providers downgrade the disk IO tier when you scale CPU only. If you resize and `iostat` still shows 90% util, the new shape didn't get more IOPS. Check the provider's instance-type table. Try rung 1 first. If three months later the same three signals come back, you have a structural problem and rung 2 is your next move. ## Rung 2 in detail — split the database off This is the bulk of the work, and it's the rung most production setups stop at forever. **What moves.** The database server, its data volume, and its backup hooks. That is the entire scope. Don't move anything else on the same trip. **What stays.** The app, the reverse proxy, cron jobs, queue workers, anything that does not directly speak SQL. Migrating the DB is enough change for one day. **The connection change.** Your app's `.env` switches from `DATABASE_URL=postgres://localhost/myapp` to `DATABASE_URL=postgres://10.0.0.5/myapp`, where `10.0.0.5` is the database VPS's private address. **Always use a private network.** Never the public IP. Both VPSes need to be in the same provider's private VPC, or stitched together with WireGuard or Tailscale. A public-IP database is a Shodan listing waiting to happen, and the egress bandwidth on most providers is metered while private traffic is free. **Latency.** In-DC private network adds about 0.3 to 1 ms per query. Your app's p50 climbs slightly. Your p99 usually stays flat or improves, because the app no longer fights the database for IO. If your app does N+1 queries per request, you'll feel that latency multiply — fix the N+1 before you blame the migration. **The cutover sequence.** The safest path keeps a fallback at every step: 1. Provision the new DB VPS. Install Postgres, configure `pg_hba.conf` for the app subnet, and verify the private network reaches it from the app box (`pg_isready -h 10.0.0.5`). 2. Restore a fresh dump on the new box ahead of the cutover, so you've proven the path works: `pg_dump -Fc mydb | ssh db-vps "pg_restore -d mydb"`. 3. On cutover day, flip a maintenance flag in the app to pause writes for 5 to 30 minutes. 4. Take a final dump and restore: `pg_dump -Fc mydb | ssh db-vps "pg_restore --clean --if-exists -d mydb"`. 5. Update `DATABASE_URL` in the app's `.env`, restart the app, clear the maintenance flag. 6. Keep the old database running, read-only, for 24 hours as a fallback. If something blows up, you flip the URL back. **What breaks.** Three things, predictably: - `pg_hba.conf` and `postgresql.conf` on the old DB are usually `localhost` only. The new DB has to listen on the private interface and accept connections from the app subnet — not from `0.0.0.0/0`. - Apps that connect to Postgres through a Unix socket (`/var/run/postgresql/.s.PGSQL.5432`) need to switch to TCP. Some ORMs default to socket; check your config. - Cron jobs on the old box that called `psql` or `pg_dump` against `localhost` now fail silently. Repoint them at the new host or move them onto the DB VPS. Once rung 2 is done and stable for a month, you will probably stop here. Most small-team SaaS does. ## Rung 3 in detail — split the app horizontally Most readers will not need this. Read it anyway, so you know what's required when you do. **The prerequisite.** Rung 2 done. You cannot horizontal-scale the app without an external database, full stop. Two app instances both pointing at a `localhost` database is two instances pointing at two different databases. **The new pieces.** - A load balancer in front: HAProxy, Traefik, Caddy, or a managed LB from your provider. Health-check `/healthz` on each app, drain on deploy. - Shared session storage if your app has sessions. Redis on the rung-2 DB box is fine for the first year — it's tiny and the network is already there. - Shared file storage for user uploads. Use S3-compatible object storage (Backblaze B2, Cloudflare R2, Wasabi, or actual S3). **Do not** bind-mount an NFS share between app instances; you will spend weekends debugging file-locking corner cases. **The two failure modes that bite.** *Stale config on one instance.* Instance A has the new feature flag, instance B doesn't. Users see flickering behaviour depending on which app they hit. Fix: deploy via a single source of truth (env vars from the load balancer, a config service, or a deploy tool that writes the same config to both boxes atomically). Never SSH into one box to "just patch this real quick." *Cron jobs running on every instance instead of one.* You scale to two app instances, and now your nightly billing email fires twice. The cheap fix is `flock`: ``` 0 3 * * * flock -n /tmp/billing.lock /usr/local/bin/run-billing.sh ``` Better is to designate one instance as the cron node, or to move scheduled work into a queue worker that only one consumer processes. ## The ladder you'll never climb Rung 4 is auto-scaling, multiple regions, blue-green deploys, full Kubernetes. It exists. It is real engineering. Most small-team SaaS lives at rung 2 forever, and that is fine. The cost of climbing past rung 3 — operational complexity, on-call burden, CI surface area, the people you have to hire to keep it healthy — exceeds the gain unless you are scaling for traffic or compliance, not for an engineering wishlist. You are not behind for stopping at rung 2. You are not unserious for stopping at rung 1. The point of the ladder is to climb only as far as your symptoms force you. ## The honest closing line Every rung is reversible. If you split the database and the latency hurts more than it helps, you can merge back in an afternoon. If you go horizontal and discover your app has a hidden dependency on local disk, you can collapse to one instance and fix the bug. The boxes are cattle, not pets. The point is to know which rung you're on, not to climb every rung. --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## Domain to Port 3000: The Mental Model Every VPS Tutorial Skips Source: https://deploytovps.com/blog/guide/domain-to-port-mental-model-vps-routing Published: 2026-05-09 Tags: domain-dns, reverse-proxy, beginner-vps, port-binding, https, mental-model You bought a domain and your app runs on port 3000, but typing example.com in a browser shows nothing. Here is the full path — DNS, kernel, proxy, app, cert — that every existing tutorial assumes you already understand. A very common first-VPS experience goes like this: "I bought example.com on Namecheap. My app is running on the VPS, listening on port 3000. When I type example.com in a browser, I get a site cannot be reached error. What am I missing?" Every existing tutorial answers the next question — how to configure Nginx, how to set up Certbot, how to bind multiple hostnames — but skips the one underneath it: what actually happens between typing a name in a browser and bytes arriving at port 3000 on a Linux box. This post is that missing layer. Once the model fits in your head, every "site is broken" message turns into a specific, locatable problem instead of a fog of unknowns. ![Domain-to-port path: browser → DNS → public IP : 443 → reverse proxy → 127.0.0.1:3000 → app](https://assets.stoicsoft.com/posts/deploytovps/guide/domain-to-port-mental-model-vps-routing/featured.png) ## 1. What "domain" actually is A domain name is a string that resolves to an IP address through DNS. That is all it does. Buying example.com from Namecheap or Cloudflare does not bind the name to your VPS, your app, or anything else. It gives you the right to publish records in DNS that say "this name → this IP". Until you publish those records, the name resolves to nothing useful, and a browser typing example.com has no destination to connect to. This is the first place beginners get confused. Owning a domain is paperwork. Routing traffic to a server is a separate, second action you take through DNS records. ## 2. The DNS A record The record that maps a hostname to an IPv4 address is called an A record. The smallest possible version looks like this: ~~~ example.com A 203.0.113.42 ~~~ The IP on the right is your VPS's public IP — the one your hosting provider showed you when you created the instance. You add this record in the DNS panel of whoever runs your nameservers, which is usually the registrar (Namecheap, Cloudflare, Porkbun) unless you delegated DNS elsewhere. Confirm the record is live with dig: ~~~bash dig +short example.com ~~~ If that returns your VPS IP, DNS is doing its job. If it returns nothing, returns the wrong IP, or hangs, the first 80% of "site cannot be reached" issues are right here. No proxy config or TLS config will help until DNS resolves correctly. ## 3. The IP-to-machine arrival Once the browser has the IP, it opens a TCP connection to either `:443` for HTTPS or `:80` for HTTP. That packet leaves your laptop, traverses the internet, and arrives at the public network interface of your VPS. The VPS kernel receives the packet and looks for a process listening on that port. If something is listening, the kernel hands the connection to that process. If nothing is listening, the kernel rejects the connection and the browser shows "connection refused" or "site cannot be reached". The diagnostic to run on the VPS itself is: ~~~bash sudo ss -tlnp | grep -E ':(80|443)' ~~~ This lists every process listening on TCP ports 80 or 443. If the output is empty, no process is bound to the public web ports — which is, in fact, the default state of a fresh Ubuntu VPS that has not been configured yet. ## 4. The first port problem Your app is on port 3000. Browsers send to 80 and 443. Nothing on the VPS is listening on 80 or 443. This is the most common stuck point on the entire path. There are exactly two ways out: - **Bind the app directly to 80 or 443.** This requires either running the app as root or granting it the `cap_net_bind_service` capability, because Linux reserves ports below 1024 for privileged processes. It also means your app is doing TLS termination itself, has to handle redirects, and competes with anything else that wants 80/443 on the same machine. - **Put a reverse proxy on 80 and 443 that forwards to 3000.** The proxy listens on the privileged ports and forwards each connection to your app on its unprivileged port. This is what every production setup uses, because it cleanly separates "talk to the public internet" from "run application code". Path two wins for almost every real deployment. The rest of this post assumes you are taking it. ## 5. What a reverse proxy actually does A reverse proxy is a process that accepts incoming connections on one port and forwards them to another process on another port. That is the whole job description. The smallest possible Nginx config that proves the concept: ~~~nginx server { listen 80; server_name example.com; location / { proxy_pass http://127.0.0.1:3000; } } ~~~ Three meaningful lines. Nginx listens on port 80, matches requests where the `Host:` header is `example.com`, and forwards the request body and headers to the local app on port 3000. The same shape works for Caddy, Traefik, and HAProxy with different syntax — the model underneath is identical. Test the config and reload: ~~~bash sudo nginx -t && sudo systemctl reload nginx ~~~ At this point, `http://example.com` should reach your app. If it does not, the problem is in one of the steps before this one. ## 6. HTTPS in two minutes Modern browsers warn loudly on plain HTTP and many APIs flat-out refuse to work without TLS. Production sites need port 443 with a real certificate, not port 80. The cheapest correct answer is Let's Encrypt via Certbot: ~~~bash sudo certbot --nginx -d example.com ~~~ Certbot proves you control the domain, fetches a free certificate, edits your Nginx config to listen on 443 with that cert, and adds a 301 redirect from 80 to 443. The renewal cron is wired up automatically. Verify the served certificate end-to-end: ~~~bash echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -dates -issuer ~~~ If the dates and issuer look right, TLS termination is in place and the public surface is now HTTPS. ## 7. The full path traced once Walk through what happens for a single browser request to https://example.com once everything is wired up: 1. Browser asks the user's configured DNS resolver for `example.com`. The resolver consults the authoritative nameservers and returns `203.0.113.42`. 2. Browser opens a TCP connection to `203.0.113.42:443`. The TLS handshake completes against the certificate Nginx is serving for that hostname. 3. The HTTPS request arrives at Nginx. Nginx reads the `Host: example.com` header and matches it to the `server` block whose `server_name` is `example.com`. 4. Nginx forwards the request to `http://127.0.0.1:3000`. The `127.0.0.1` matters: it is the loopback interface, reachable only from the VPS itself. Your app stays invisible to the public internet. 5. Your app on port 3000 responds. Nginx relays the response bytes back over the same TLS connection to the browser. That is the whole pipeline. Every "my site is broken" question maps to a specific link in this chain — and the diagnostic is always the same shape: confirm step N works before suspecting step N+1. ## 8. The seven things that go wrong In rough order of frequency, these are the failures that account for almost every stuck deploy: - **DNS not propagated.** Wait, or test from a network you have not used: `dig +short example.com @1.1.1.1`. If your resolver gives a different answer than 1.1.1.1, the record has not propagated yet. - **Cloud firewall blocks 80/443.** Many providers (AWS, GCP, Hetzner Cloud, DigitalOcean) maintain a firewall layer separate from the VPS itself. Open 80 and 443 in the provider console — security groups, cloud firewall, network rules, depending on vendor. - **UFW or iptables blocks 80/443.** On the VPS itself, the host firewall may be active. Check with `sudo ufw status` and allow `Nginx Full` or 80/tcp and 443/tcp explicitly. - **Nginx not running, or config has a typo.** `sudo nginx -t && systemctl status nginx`. A failed reload often leaves the old process running, which is why the config test matters before the reload. - **App not actually listening on the upstream port.** Run `sudo ss -tlnp | grep 3000` on the VPS. If nothing matches, the proxy is forwarding to a void. - **App listening on `0.0.0.0:3000` exposed publicly.** This means anyone on the internet can hit your app directly on port 3000, bypassing the proxy and any TLS or auth in front of it. Rebind the app to `127.0.0.1:3000` so only the local proxy can reach it. - **Certificate not yet issued.** `sudo certbot certificates` lists every cert Certbot manages and its expiry. If the domain you expect is not there, the issuance step did not finish. Each of these maps cleanly to a step in the trace above. That is the point of the trace. ## Internal links - Guide: [Multiple Domains on One VPS](/guide/multiple-domains-vps) - Guide: [Multi-App VPS Port Collisions](/guide/multi-app-vps-port-collisions) - Guide: [Intermittent 502 from Stale Proxy Upstream](/guide/intermittent-502-stale-proxy-upstream) - Guide: [Let's Encrypt Renew + Reload Pattern](/guide/lets-encrypt-renew-reload-docker-proxy) - ServerCompass: [Pick a VPS host that fits this stack](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) ## The deeper rule Every working VPS-hosted website is the same five-component stack: DNS, kernel TCP, reverse proxy, app, certificate. Each piece is small and well-defined; the system is the composition of the five. Once that model fits in your head, the next outage stops feeling like a mystery. The question shrinks from "why is my site broken" to "which of the five components is misconfigured" — and you already know how to check each one. --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## Intermittent 502s on a Self-Hosted VPS: It's a Stale Proxy Upstream Source: https://deploytovps.com/blog/guide/intermittent-502-stale-proxy-upstream Published: 2026-05-07 Tags: reverse-proxy, stale-upstream, docker-network, intermittent-502, traefik, nginx Apps that work for hours, then start failing 502 until you reload the proxy, almost always have a stale upstream — IP drift, DNS cache, or a dropped network attachment. Here's how to diagnose it in 60 seconds and the four fixes that eliminate the bug class. Your app worked all morning. Around lunch, half the requests start returning 502 — the other half work fine. You restart the proxy and everything's back to normal. By dinner it's broken again. This is the most under-diagnosed failure mode in self-hosting. The proxy config is correct. The backend container is up and healthy. Curl from inside the backend container hits the app on the right port. But the route between the two has gone stale: the proxy is still pointing at an address that no longer answers, and it has no idea. A reload re-resolves the upstream and the symptom vanishes — until the next container recreate, network reattach, or DNS cache expiry. The reason it keeps coming back is that none of the obvious checks find it. The proxy is "running." The backend is "running." Logs on either side look ordinary right up until the failures start. The bug lives in a piece of state nobody usually inspects: the proxy's cached idea of where the backend is. ![Stale upstream timeline — proxy points at the old backend IP after a container recreate; requests fail intermittently until reload](https://assets.stoicsoft.com/posts/deploytovps/guide/intermittent-502-stale-proxy-upstream/featured.png) ## What "stale upstream" actually means When a reverse proxy forwards a request, it has to know the backend's address. That address comes from one of three sources: a hard-coded IP in the config, a hostname resolved through DNS, or a Docker service name resolved through Docker's embedded DNS at `127.0.0.11`. In every case, the proxy resolves once and then caches the result — sometimes for the lifetime of the worker process, sometimes for a configurable TTL, sometimes until reload. Stale upstream is what happens when that cached address stops being correct. The backend got recreated on a new IP. The container left and rejoined the network. A compose `up -d` replaced the service while the proxy stayed up. The DNS record TTL expired but the proxy never re-resolved. From the proxy's perspective everything is fine; it's still happily opening connections to the address it learned at startup. From the user's perspective, half their requests vanish into a black hole. The defining tell is that a proxy reload — not a restart of the backend, a reload of the proxy — fixes it instantly. That is the diagnostic. ## The five concrete causes **Container IP drift after recreate.** When you `docker rm` and `docker run` a backend on the same user-defined network, Docker usually assigns it a new IP from the network's pool. Configs that hard-coded the old IP — `proxy_pass http://172.18.0.4:3000;` — keep pointing at an address that nothing answers on. Diagnose with `docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'` and compare to whatever the proxy config has baked in. **DNS caching inside the proxy process.** Nginx is the loudest offender here. A bare `proxy_pass http://backend:3000;` resolves the name once at config-load time and never again. If the backend's IP changes, nginx happily forwards to the dead address forever. The fix is to declare a `resolver` and use a variable in the proxy_pass URL so nginx is forced to re-resolve on each request — covered in the fix patterns below. **Network reattach drift.** Containers can be disconnected and reconnected to a network without restarting either container — `docker network disconnect` followed by `docker network connect`, or a compose change that rewires services. The new attachment usually gets a new IP, and proxies that cached the old one keep aiming at where the backend used to be. Run `docker network inspect ` and confirm the backend appears with the IP the proxy thinks it should have. **Dropped keepalive on a now-dead backend.** Some proxies maintain long-lived TCP connections to upstreams to avoid handshake overhead. If the backend instance behind that connection silently dies — OOM kill, crash loop, segfault — the proxy can keep reusing a half-broken socket until it noticeably fails, often returning `502` or `connection reset` for a window of requests before opening a new connection. From inside the proxy container: `docker exec curl -v http://:/healthz`. If the curl fails but `docker ps` shows the backend running, you are probably staring at it. **Multiple-hostname route precedence misordering.** Two `server { server_name ... }` blocks on the same listen port. The most-specific name should win, but if the request matches a wildcard in both, nginx falls back to whichever loaded first in lexicographic file order. Symptoms look intermittent because the "wrong" backend may be healthy most of the time. Diagnose with `nginx -T 2>/dev/null | grep -E '^(server_name|listen|proxy_pass)'` and read the first matching block by hand. ## The 60-second triage script Drop this into a file the next time intermittent 502s start. It runs the four highest-yield checks in order and prints exactly what's wrong. ```bash #!/usr/bin/env bash PROXY=${1:-nginx} BACKEND=${2:-backend} PORT=${3:-3000} echo "1) Backend container exists and is running:" docker ps --filter "name=^${BACKEND}$" --format '{{.Names}} {{.Status}}' echo "2) Backend's current IP on each attached network:" docker inspect "$BACKEND" --format \ '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' echo "3) Proxy can reach backend by service name:" docker exec "$PROXY" sh -c "curl -sS -o /dev/null -w '%{http_code}\n' --max-time 3 http://${BACKEND}:${PORT}/" \ || echo " unreachable from proxy netns" echo "4) What the loaded proxy config thinks the upstream is:" docker exec "$PROXY" nginx -T 2>/dev/null | grep -E 'proxy_pass|server_name' | head -20 ``` If step 2 prints a different IP than step 4 references, you have IP drift. If step 3 fails but `docker ps` says the backend is up, you have a network attachment or keepalive issue. If step 4 shows a hard-coded IP at all, that is the bug — fix it before doing anything else. ## The four fixes that eliminate the bug class If you apply these four patterns, you will not have the stale-upstream symptom again. **Resolve by service name, not IP.** When both containers are on the same Docker user-defined network, Docker's embedded DNS at `127.0.0.11` resolves service names to whatever address the container currently has. Use the service name in the proxy config and let Docker do the bookkeeping. A minimal nginx upstream looks like this: ```nginx server { listen 80; server_name app.example.com; location / { proxy_pass http://backend:3000; } } ``` Combined with a compose file where both services share a network, this survives container recreates without touching the proxy. **Force re-resolution on a TTL.** The above still has nginx's resolve-once-at-startup problem. The fix is one declaration plus a variable: ```nginx resolver 127.0.0.11 valid=10s ipv6=off; server { listen 80; server_name app.example.com; set $upstream backend; location / { proxy_pass http://$upstream:3000; } } ``` The variable in `proxy_pass` forces nginx to use the resolver at request time instead of at config-parse time. The `valid=10s` caps how long a resolved IP is trusted. Trade a tiny per-request lookup for an entire class of bug. **Use Traefik with Docker labels.** Traefik watches the Docker socket for container events and rewrites its routing table the moment a backend appears, disappears, or moves. There is no upstream cache to go stale. A minimal label set on the backend service: ```yaml services: backend: image: ghcr.io/you/backend:latest labels: - "traefik.enable=true" - "traefik.http.routers.backend.rule=Host(`app.example.com`)" - "traefik.http.services.backend.loadbalancer.server.port=3000" networks: [web] ``` Recreate the container on a new IP and Traefik adjusts before the next request lands. **Healthcheck-driven removal.** A proxy that pulls upstreams from a registry can drop unhealthy backends automatically — Traefik via Docker labels, Caddy with `dynamic_upstreams`, HAProxy with the runtime API. A proxy that doesn't will keep sending traffic at a dead address until you reload it. If you stay on plain nginx, at least add a healthcheck (`upstream` modules in nginx-plus, or `nginx-upstream-dynamic-servers`) so dead backends get marked down instead of silently failing requests. ## Reload, don't restart When you do need to shake out a stale resolution, use the reload path, not the restart path. Restart drops every in-flight connection. Reload keeps them and only refreshes the worker config. - nginx: `nginx -s reload` (or `docker exec nginx nginx -s reload`). - Traefik: no manual action — it picks up Docker events automatically. - Caddy: `caddy reload --config /etc/caddy/Caddyfile` or `POST /load` against the admin API. - HAProxy: a hot reload via `haproxy -sf $(pidof haproxy)` or `systemctl reload haproxy`. The reflex of `docker restart nginx` is fine for development. In production it kills user requests for no reason — every modern proxy supports a graceful reload, and the operational cost of using it is zero. ## What this looks like in logs Pattern recognition on the proxy log saves you the triage step entirely. These are the four phrases to scan for: ``` upstream timed out (110: Connection timed out) while connecting to upstream connect() failed (111: Connection refused) while connecting to upstream no live upstreams while connecting to upstream host not found in upstream "backend" ``` Every one of these means the same family of problem: the proxy tried to talk to an address that did not answer. `Connection refused` is the classic IP-drift case — the address is still routable on the network but nothing is listening. `Connection timed out` usually means the IP is no longer attached to anything. `No live upstreams` is what happens when nginx has marked all members of an `upstream` block as failed. `Host not found in upstream` is the resolver giving up on a service name — usually because the backend container is gone entirely. ## The deeper rule Restarts are not a fix. They are a flag. Every "I restarted it and it worked" moment in self-hosting is the system telling you that some piece of state — a cached resolution, a half-open connection, a stale config — is not being refreshed when the world changes around it. Healthy infrastructure does not need to be restarted to stay correct. Treat the proxy as the single piece of plumbing that has to trust its own routing table. If it doesn't, every other layer below it will look broken instead. --- ## Docker Volume Backups Without Restore Drills Are Just Hope Source: https://deploytovps.com/blog/guide/docker-volume-restore-drill Published: 2026-05-06 Tags: docker-volumes, backup-restore, rollback, self-host, disaster-recovery Most self-hosters back up Docker volumes nightly and never restore them. A scripted restore drill is what turns a backup into something you can rely on. Your backup script has run every night for 11 months. You have not once restored from it. What you have is not a backup — it is a hope. This is the most common self-hosting failure mode that nobody admits. Backup jobs report success, archives accumulate, monitoring stays green, and the operator feels covered. Then a volume gets corrupted, a container deletes its own data on a bad migration, or a disk fails — and the first time the restore actually runs is during the outage. That is also when you discover the missing sidecar volume, the wrong mount path, the schema mismatch, and the encryption key you backed up inside the very volume you are trying to restore. ![Restore drill loop: backup, restore to clean target, smoke-test, tear down, log result](https://assets.stoicsoft.com/posts/deploytovps/guide/docker-volume-restore-drill/featured-v2.png) ## The confidence gap A backup job verifies one thing: bytes were written somewhere. It does not verify that those bytes, restored into a fresh container, will boot the application and serve users. Those are different claims, and most self-host setups only test the first one. The asymmetry is brutal. Backups run nightly. Restores run never. The first real restore is always under pressure, with stale documentation, half-remembered commands, and a ticking clock. By the time you find out the dump is incomplete, the original is already gone. The fix is not better backup tooling. The fix is a scheduled, scripted exercise that proves the restore path actually works — on a normal Tuesday, while everything is healthy, and you have time to fix what you find. ## What a restore drill actually is A restore drill is a small, repeatable workflow that: - Spins up a clean target — a temp container, a sidecar volume, a separate VM, or a different volume namespace. - Restores the latest backup into it. - Boots the dependent application against the restored data. - Asserts a known-good check — a login succeeds, a known row exists, an API smoke test returns 200. - Tears the target down so it does not accumulate. - Logs result and timing. The drill is not "did the file copy back." The drill is "did the application come up, did the data look right, and how long did the whole thing take." That last bit matters more than people realize. Your recovery time objective lives or dies on actual restore duration, not on backup duration. ## Concrete drill: Postgres in Docker Most self-hosters have a Postgres volume somewhere. Here is the full loop end to end. Backup, which you almost certainly already have: ```bash docker exec pg pg_dump -U postgres myapp > /backups/myapp-$(date +%F).sql ``` The drill spins up a sidecar Postgres into a fresh volume, restores into it, and runs a smoke check: ```bash # pick the most recent backup LATEST=$(ls -1t /backups/myapp-*.sql | head -1) # clean target docker volume create pg_drill docker run -d --name pg_drill \ -e POSTGRES_PASSWORD=drill \ -v pg_drill:/var/lib/postgresql/data \ postgres:16 # wait for it to come up until docker exec pg_drill pg_isready -U postgres; do sleep 1; done # restore docker exec -i pg_drill psql -U postgres -c "CREATE DATABASE myapp;" cat "$LATEST" | docker exec -i pg_drill psql -U postgres myapp # smoke check — known-good assertions docker exec pg_drill psql -U postgres myapp -c "SELECT count(*) FROM users;" docker exec pg_drill psql -U postgres myapp -c "SELECT MAX(created_at) FROM events;" # teardown docker rm -f pg_drill docker volume rm pg_drill ``` The smoke check is the part most operators skip. Without it the drill only proves that `psql` did not error — which is a weak claim. The `count(*)` and `MAX(created_at)` together prove rows exist and the latest write is recent. If either looks wrong, the backup is wrong. ## Concrete drill: bind-mount apps (Vaultwarden, Nextcloud) Apps with bind mounts are different. The data lives in a host directory, often with strict UID/GID expectations. The drill needs to replicate those. ```bash # assume a restic snapshot of /srv/vaultwarden/data RESTORE_DIR=/tmp/drill-vaultwarden rm -rf "$RESTORE_DIR" && mkdir -p "$RESTORE_DIR" restic -r /backups/restic restore latest --target "$RESTORE_DIR" # boot a fresh container against the restored data docker run -d --name vw_drill \ -p 18080:80 \ -v "$RESTORE_DIR/srv/vaultwarden/data":/data \ vaultwarden/server:latest # smoke check — log in with a known test account sleep 5 curl -fsS -X POST http://localhost:18080/identity/connect/token \ -d "grant_type=password&username=drill@example.com&password=$DRILL_PW&scope=api" \ | grep -q access_token && echo PASS || echo FAIL # teardown docker rm -f vw_drill rm -rf "$RESTORE_DIR" ``` Pick a port that does not collide with the live container, and use a separate test account that exists in the production data. If the login fails, your backup is missing something — usually the encryption key file, the config file, or the right ownership. ## What the drill catches that the backup never tests A nightly backup job tests storage. The drill tests reality. The recurring failure modes: - **UID/GID drift.** A container rebuild changes the user inside the image — `999` becomes `1000`, or the official image switches from root to a named user between minor versions. Restored files keep the old ownership. The app boots to permission denied on its own data directory. - **Sidecar volumes you forgot.** The database is backed up. The uploads directory, sitting in a sibling bind mount, is not. Or the Redis volume that stores session tokens is missing, and every user is silently logged out after restore. - **Env var coupling.** The restored data references a secret that lives in `.env` on the host — a database encryption key, an OIDC client secret, an SMTP password. The host is gone. The data is technically intact and practically unreadable. - **Schema migrations not in the backup.** You upgraded the app last week, the migration ran in place, but the dump was taken before the migration. The restored DB does not match the new app version. The app refuses to start, or worse, starts and corrupts further. - **The wallet inside the safe.** The encrypted volume's key is itself stored in the encrypted volume — Vaultwarden's `rsa_key.pem`, a LUKS keyfile, a SOPS age key. You can restore the file. You cannot open it. - **RTO blown by restore time.** A 200 GB Postgres restore that takes four hours is not a backup if your tolerance is one. Compressed dumps decompress slowly. WAL replay is single-threaded. You learn this only by timing it. You do not find these on a quiet Tuesday by reading documentation. You find them by running the drill — and you fix them when there is no clock running. ## A realistic cadence Drill weekly for production data. Drill monthly for "we would survive losing this" data. Rotate which backup you restore from — sometimes latest, sometimes seven days old, sometimes thirty days old. Old backups catch retention bugs: the prune script that silently stopped working, the cron job that silently stopped writing six weeks ago, the bucket that hit a quota and started rejecting uploads while the local job kept reporting success. If you only have time for one drill per month, drill against a backup that is at least a week old. That catches the failure mode where last night's backup happens to work but everything before it is corrupt — which is exactly the situation you end up in when you discover corruption a few days after it started, and the only "good" backup is older than you remembered. ## Make it a script, not a habit Habits die. Scripts run on cron. The skeleton: ```bash #!/usr/bin/env bash # /usr/local/bin/restore-drill.sh set -euo pipefail START=$(date +%s) TARGET=/tmp/drill-$(date +%s) LATEST=$(ls -1t /backups/myapp-*.sql | head -1) cleanup() { docker rm -f pg_drill 2>/dev/null || true docker volume rm pg_drill 2>/dev/null || true rm -rf "$TARGET" } trap cleanup EXIT docker volume create pg_drill >/dev/null docker run -d --name pg_drill -e POSTGRES_PASSWORD=drill \ -v pg_drill:/var/lib/postgresql/data postgres:16 >/dev/null until docker exec pg_drill pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done docker exec -i pg_drill psql -U postgres -c "CREATE DATABASE myapp;" >/dev/null cat "$LATEST" | docker exec -i pg_drill psql -U postgres myapp >/dev/null ROWS=$(docker exec pg_drill psql -U postgres myapp -tAc "SELECT count(*) FROM users;") ELAPSED=$(( $(date +%s) - START )) if [ "$ROWS" -gt 0 ]; then RESULT="PASS rows=$ROWS elapsed=${ELAPSED}s backup=$(basename "$LATEST")" else RESULT="FAIL rows=$ROWS elapsed=${ELAPSED}s backup=$(basename "$LATEST")" fi echo "$(date -Iseconds) $RESULT" >> /var/log/restore-drill.log curl -fsS -d "$RESULT" https://ntfy.sh/your-drill-channel >/dev/null ``` Run it from cron. If the notification stops arriving, the drill is broken — which is exactly the signal you want, weeks before an actual outage. ## The two failure modes that humble everyone Two patterns repeat across every operator who has been through a real restore: The first is the **out-of-volume dependency**. You restore the data perfectly. The app boots in a half-broken state because something outside the volume — a config file under `/etc`, an environment variable, a TLS certificate, a Cloudflare API token — was never part of the backup. The data is fine. The system is not. Fix: every drill should boot the app on a host without that external state and see what breaks. The second is **RTO denial**. The restore works, but it takes four hours, and your business needed it back in one. You did not have a backup problem; you had a restore-time problem. Fix: time every drill, log it, and treat regressions in restore time as bugs. A backup is a claim. A drill is a proof. Until you have run the restore, you have one of these and not the other — and the difference only becomes visible when it is too late to fix. --- **Related in the StoicSoft network** If you run monitoring, uptime checks, or alerting across self-hosted apps like the ones above, [ServerCompass](https://servercompass.app) is the StoicSoft network's tool for wiring tiered severity, flap suppression, and low-noise alerts into a single dashboard. --- ## Self-Host a Public Load Balancer? Most Small Teams Need a Reverse Proxy Instead Source: https://deploytovps.com/blog/guide/self-host-public-load-balancer-decision-guide Published: 2026-05-06 Tags: load-balancer, reverse-proxy, traefik, caddy, haproxy, small-team-devops Most one-VPS teams asking about self-hosting a public load balancer actually need a reverse proxy. Here is the decision framework, the realistic Caddy/Traefik/Nginx/HAProxy tradeoffs, and the no-downtime deploy pattern that does not require a multi-host LB. You have one app on one VPS. Someone in the team channel asks whether you should put a load balancer in front of it. The honest answer is usually no, but you do need a reverse proxy, and most people calling it a load balancer mean exactly that. That confusion is where the whole topic gets foggy. Once the words are clean, the decision is short. ![Decision tree: reverse proxy vs public load balancer for small teams](https://assets.stoicsoft.com/posts/deploytovps/guide/self-host-public-load-balancer-decision-guide/featured-v2.png) ## Reverse proxy vs load balancer — name it correctly first A reverse proxy sits in front of one or more upstream apps, terminates TLS, routes by hostname or path, and forwards requests. A single upstream is fine. Caddy, Traefik, Nginx, and HAProxy can all play this role. So can managed offerings like Cloudflare Tunnel. A load balancer distributes traffic across two or more upstream instances. Round-robin, least-conn, hash-by-IP, weighted shifting — those are load-balancer concerns. With a single upstream, "load balancing" is a no-op. The same binary may do both jobs, but the operational shape is different. If you have one VPS running one app container or one process, you do not have a load-balancing problem. You have a TLS-termination-and-routing problem. That is a reverse proxy. Naming it correctly clears 80 percent of the question. The remaining 20 percent is genuinely about distributing traffic. ## When you actually need a public load balancer A few concrete triggers move you from "reverse proxy" to "load balancer": - More than one app instance behind the same hostname, on the same host or across hosts. - Blue-green or canary deploys, where two versions exist at once and traffic shifts between them. - Zero-downtime rolling restart on a single host. This is still doable with one proxy + multiple containers, but the proxy is now load balancing. - A multi-host fleet sharing a single public hostname. - Per-region or per-tenant routing rules that go beyond simple host matching. If none of those apply, you are looking for a reverse proxy. Stop shopping for HAProxy. There is also a softer trigger: someone on the team has heard "production needs a load balancer" enough times that they assume one VPS without an LB is unprofessional. It is not. Production needs reliable TLS, predictable routing, and a graceful deploy story. Distribution across multiple instances is a separate problem you take on when traffic, fault tolerance, or rollout strategy demands it. ## The realistic options Five tools dominate small-team conversations. Pick by what you already run, not by what looks shiniest. ### Caddy Zero-config TLS is the headline. Drop a `Caddyfile`, get automatic Let's Encrypt, done. ```caddyfile example.com { reverse_proxy app:3000 } ``` Caddy does load balancing too, but its config story for dynamic upstreams is weak. If you need targets to come and go automatically based on container labels or service discovery, Caddy will fight you. As a single-upstream reverse proxy, it is the lowest-friction choice on the list. ### Traefik Traefik's whole reason for existing is dynamic config. You label containers, Traefik picks them up. ACME is built in. ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`example.com`)" - "traefik.http.routers.app.tls.certresolver=le" - "traefik.http.services.app.loadbalancer.server.port=3000" ``` For container-heavy stacks, this is the default. The cost is conceptual surface area: routers, services, middlewares, providers, entrypoints. New operators get lost. Once it clicks, deploys become "start the new container, stop the old one." ### HAProxy The original L4/L7 balancer. Rock-solid, fast, observable. Configuration is plain-text, statically loaded, not friendly to label-driven workflows. If you already know HAProxy, it is excellent. If you do not, learning it just to balance two containers on one VPS is overkill. Skip until traffic or correctness demands it. ### Nginx Universally available, well-documented, and what every Stack Overflow answer assumes. Reverse proxy and load balancing both work fine: ```nginx upstream app { server 127.0.0.1:3001; server 127.0.0.1:3002; } server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { proxy_pass http://app; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Dynamic upstream changes mean editing config and reloading, or paying for Nginx Plus. For a stable two-container blue-green, that is fine. For aggressive autoscaling, it is not. ### Managed ingress Cloudflare Tunnel, your cloud provider's LB, Fly's edge, Railway's router. These hide the entire problem until traffic justifies owning it. The tradeoff is vendor lock-in and a bill that grows with traffic. For early-stage teams on one VPS, a managed front-end plus a local reverse proxy is often the lowest-drama option. You get TLS, DDoS absorption, and a public IP that does not depend on your VPS reboot schedule. The "self-host vs managed" axis is not all-or-nothing: most healthy small-team setups put a managed edge in front and a local proxy behind it. ## No-downtime instance switching on one VPS Most "do I need a load balancer" questions are really "how do I deploy without dropping requests." On a single VPS, you do not need a multi-host LB. You need two app containers and a proxy that can hot-swap between them. The pattern, in order: 1. Old container `app-v1` is running on port 3001 and live. 2. Start `app-v2` on port 3002. Wait for its healthcheck to pass. 3. Tell the proxy to send new traffic to `app-v2`. 4. Wait for in-flight requests on `app-v1` to drain. 5. Stop `app-v1`. In Traefik, the swap is a label change plus a `docker compose up -d` of the new container. Old and new can coexist behind the same router using weighted services: ```yaml http: services: app: weighted: services: - name: app-v1 weight: 0 - name: app-v2 weight: 100 ``` Flip the weights, reload Traefik's file provider, done. In Nginx, you keep both upstreams in the pool and mark the old one `down` once the new one is healthy: ```nginx upstream app { server 127.0.0.1:3001 down; server 127.0.0.1:3002; } ``` `nginx -s reload` re-reads the file without dropping connections. This is plain reverse-proxy mechanics with two upstreams — call it load balancing if you want, but the operational shape is the same. ## The decision framework Ignore the search results. The flow is short: - One VPS, one app, no rollout pain → use Caddy or Traefik as a reverse proxy. Stop reading. - One VPS, one app, you want zero-downtime deploys → run two app containers, put a proxy in front, hot-swap upstreams. Traefik with labels or Nginx with `upstream` + reload both work. - Multiple VPSes, one public hostname → you are past "self-host the LB" territory. Use a managed LB (Cloudflare, your cloud's L4/L7 balancer) or commit to running HAProxy properly with health checks, observability, and config management. - Container-heavy, frequent changes, no managed budget → Traefik. Its dynamic config is the whole point. - Already running Nginx, low change rate → keep Nginx. Switching for ergonomics is not worth a migration. - TLS feels scary → Caddy. Automatic certs remove a category of incidents. If the answer requires a 30-minute discussion, default to "managed ingress in front, simple reverse proxy on the VPS." That setup is hard to mess up and easy to outgrow gracefully. ## Common traps A few patterns repeatedly turn small-team self-hosted balancing into outages. **Config drift between deploys.** Hand-edited `nginx.conf` on the VPS does not survive a host rebuild. Keep proxy config in the repo or in a config-management tool. The proxy is part of the application, not part of the server. **Certificate rotation breakage.** A reverse proxy that does not reload after `certbot renew` will serve an expired cert two months later. Wire the reload into the renewal hook. Verify with `openssl s_client -connect host:443` against the live socket, not the file on disk. **Sticky sessions assumed but not configured.** If your app stores session state in memory, a round-robin balancer will scatter users across instances and log them out at random. Either configure the proxy for cookie-based stickiness or move sessions to Redis. Picking neither is the failure mode. **Healthchecks that pass while the app is broken.** A `/` route that returns 200 because the framework is alive tells you nothing about the database connection or downstream API. Add a real `/healthz` that exercises the dependencies the request path needs, and point the proxy's healthcheck at it. **No graceful drain on shutdown.** When you stop `app-v1`, in-flight requests die. The fix is application-level: handle SIGTERM, stop accepting new connections, finish what you have, then exit. Without it, "zero-downtime deploy" still drops a handful of requests every time. **Two systems managing one cert lifecycle.** If Traefik handles ACME and Certbot also runs on the host for the same domain, debugging gets opaque fast. Pick one source of truth and disable the other. The shortest-path answer for most early-stage teams: put a reverse proxy on the VPS, terminate TLS there, run one or two app containers behind it, and only call it a load balancer when there are actually multiple instances to balance across. If you outgrow that, you will know — the symptoms are loud, and by then you will have the context to choose HAProxy, a managed LB, or a multi-host setup with eyes open. --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## Let's Encrypt Renewed Fine. Your Proxy Still Serves the Old Cert. Here's the Safe Fix. Source: https://deploytovps.com/blog/guide/lets-encrypt-renew-reload-docker-proxy Published: 2026-05-06 Tags: lets-encrypt, certbot, nginx, docker, ssl Certificate renewal is only half the job. If Nginx or Traefik keeps the old cert loaded, you need a safe reload pattern, not a full restart roulette. A very common self-hosting experience goes like this: 1. Certbot says the renewal succeeded. 2. OpenSSL shows the new expiry date on disk. 3. Your browser still sees the old certificate. At that point people start doubting everything: DNS, Let us Encrypt, browser cache, Docker volumes, Traefik, Nginx, all of it. The root problem is usually simpler. Renewal updated the certificate files, but the reverse proxy did not reload them into the running process. That is why the safe pattern is not just renew the cert. It is renew the cert, then reload the proxy without taking the site down. ![Safe reload workflow: renew certificate, verify on disk, reload the reverse proxy, then verify the served certificate](https://assets.stoicsoft.com/posts/deploytovps/guide/lets-encrypt-renew-reload-docker-proxy/safe-reload-workflow.png) ## Why this happens Most proxies load certificate material into memory when they start or reload. That means the certificate on disk and the certificate currently being served can diverge. Common scenarios: - Certbot renewed the files successfully, but Nginx never reloaded. - The proxy runs in Docker and the renewed certs are on the host, but the container did not pick up the change. - A restart was required, but the operator avoided it because they did not want downtime. - The reload command exists, but nobody wired it into the renew flow. If you remember one thing from this whole topic, let it be this: A renewed certificate is not live until the serving process re-reads it. ## The safe reload rule Prefer reload over restart whenever the proxy supports it. Why: - Reload re-reads config and certificate files. - Existing connections are usually handled gracefully. - Downtime risk is much lower than a full stop and start. For Nginx, that means nginx -s reload or systemctl reload nginx. For Traefik and Caddy, behavior differs because certificate management is more integrated, but the principle is the same: verify whether the running process hot-reloads certificate changes or needs a signal or restart. ## Pattern 1: Certbot on the host with Nginx on the host This is the cleanest setup. Use a deploy hook so the reload runs only after a successful renewal: ~~~bash sudo certbot renew \ --deploy-hook "systemctl reload nginx" ~~~ If your distro uses the packaged timer or cron, put the hook in a renewal config or script so every automated run behaves the same way. Before trusting it, test the whole chain safely: ~~~bash sudo certbot renew --dry-run sudo nginx -t sudo systemctl reload nginx ~~~ The config test matters. A broken config plus blind reload automation is how people turn certificate maintenance into an outage. ## Pattern 2: Certbot on the host, Nginx inside Docker This is where people get tripped up. The host updates the files, but the container is the process actually serving TLS. You need two things: - the cert path mounted into the container, - a reload command sent to the container after renewal. Example deploy hook: ~~~bash #!/usr/bin/env bash set -e docker exec nginx-proxy nginx -t docker exec nginx-proxy nginx -s reload ~~~ Then wire it into Certbot: ~~~bash sudo certbot renew --deploy-hook /usr/local/bin/reload-nginx-container.sh ~~~ Key point: test inside the container, because that is the config and file view Nginx is actually using. ## Pattern 3: Nginx and Certbot both inside Docker Compose This setup works, but the automation surface gets wider. You usually need: - a shared volume for the certificate path, - a certbot service or sidecar, - a post-renew action that reaches the running proxy container. A simple Compose pattern is: ~~~yaml services: nginx: image: nginx:alpine volumes: - letsencrypt:/etc/letsencrypt certbot: image: certbot/certbot volumes: - letsencrypt:/etc/letsencrypt volumes: letsencrypt: ~~~ The missing part is usually the reload. Renewing in a sidecar does nothing by itself if Nginx keeps the old cert in memory. ## Pattern 4: Traefik Traefik is different from host-Certbot-plus-Nginx because it often manages Let us Encrypt directly through its ACME integration. In that mode, Traefik usually handles certificate refresh and serving without an external Certbot hook. That is one reason many self-hosters prefer it. Where confusion appears: - the operator mixes external Certbot with Traefik-managed certs, - the ACME storage is not persisted, - or the wrong router or service is actually serving the domain. If Traefik manages ACME itself, do not bolt on a second renewal system unless you have a strong reason. Two certificate managers touching the same domain workflow is an easy way to create opaque failures. ## Pattern 5: Caddy Caddy behaves similarly to Traefik in spirit: certificate management is built into the proxy experience. That means the usual host-level Certbot hook pattern is often unnecessary. If you are still running Certbot on the side with Caddy, stop and verify whether you are solving the same problem twice. ## How to verify the fix correctly Do not stop at renew command succeeded. Verify the served certificate. Useful checks: ### Check the file on disk ~~~bash openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates ~~~ ### Check what the server is actually serving ~~~bash echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -dates -issuer -subject ~~~ If these dates differ, your reload path is broken. ### Check proxy config before reload ~~~bash nginx -t ~~~ Or inside Docker: ~~~bash docker exec nginx-proxy nginx -t ~~~ ### Confirm the process saw the new cert After reload, run the OpenSSL client check again and compare expiry. That end-to-end check is more reliable than assuming a browser refresh tells the full story. ## The failure modes to avoid ### Blind full restarts People often use docker restart or systemctl restart because it is easy to remember. It works, but it is heavier than needed and more likely to interrupt active traffic. Use reload if your proxy supports it. ### Reload without config test Automating a reload without a config test is risky. A syntax error or bad include path can turn a healthy site into an outage right after renewal. ### Renewal without volume sanity In Docker, make sure the proxy container is reading the same certificate path the renewer is updating. Wrong mounts create the illusion of successful renewal while the live proxy watches a different directory. ### Two systems managing one cert lifecycle Pick one source of truth: - Certbot plus reload hooks, or - Traefik or Caddy native ACME. Mixing both creates debugging pain fast. ## A boring automation recipe that works If you want the low-drama version for Nginx: 1. Keep certificate files in one well-known path. 2. Add a deploy hook that runs a config test plus reload. 3. Test with certbot renew dry-run. 4. Verify the served cert with OpenSSL client. 5. Monitor expiry so you know if the automation silently stops working. That is enough for most VPS setups. ## Internal links - Guide: [SSL Certificates on VPS](/guide/ssl-certificates) - Guide: [Traefik SSL on VPS](/guide/traefik-ssl) - Guide: [Nginx Reverse Proxy for Node.js](/guide/nginx-reverse-proxy) - Guide: [GitHub Actions Deploy to VPS](/guide/github-actions-vps) - ServerCompass: [Automate proxy configuration and repeatable deploys](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) ## Closing principle A certificate file updating on disk is not the finish line. The finish line is the live proxy serving the new certificate without downtime. Treat renewal and reload as one workflow, prefer graceful reloads to blunt restarts, and verify the served cert rather than trusting the renewal log. That is the difference between it renewed and the site is actually fixed. --- ## When TLS at the Reverse Proxy Is Enough on a Single VPS (and When It Is Not) Source: https://deploytovps.com/blog/guide/reverse-proxy-edge-tls-vs-end-to-end Published: 2026-05-06 Tags: tls, reverse-proxy, nginx, traefik, vps If Nginx or Traefik terminates HTTPS on the same VPS that runs your app, internal TLS is usually unnecessary. The real question is where the trust boundary sits. Self-hosters often hit the same SSL question after they get the first certificate working: If HTTPS terminates at Nginx or Traefik, do I also need TLS between the proxy and my app? The confusing part is that both yes and no appear online, often with strong confidence. The practical answer is simpler than the theory-heavy versions make it sound: On a single VPS, with the reverse proxy and the app on the same trusted host or private Docker network, proxy-level TLS termination is usually enough. You add internal TLS only when traffic crosses a trust boundary that you do not fully control. That is the model. ![Trust-boundary decision diagram showing when proxy-level TLS is enough and when end-to-end TLS is required](https://assets.stoicsoft.com/posts/deploytovps/guide/reverse-proxy-edge-tls-vs-end-to-end/trust-boundary-decision.png) ## Start with the trust boundary, not the certificate count People approach this as a crypto question. Most of the time it is a topology question. Ask: - Does the traffic leave the machine? - Does it cross a shared or untrusted network? - Is there a compliance requirement for encryption at every hop? - Are there intermediaries you do not control? If the answer to all of those is no, end-to-end TLS inside the same VPS is usually complexity without meaningful security gain. ## The common single-VPS pattern Typical self-host layout: ~~~ Internet to port 443 on VPS to Nginx or Traefik to app on localhost 3000 or a private container port ~~~ In that setup: - External traffic is encrypted over the public internet. - The proxy terminates TLS. - The app receives plain HTTP from localhost or a private bridge network. That is normal. It is also how a large amount of production infrastructure works. The security assumptions are: - You trust the VPS itself. - You trust the local kernel networking boundary. - You are not exposing the app port publicly. - You are comfortable that compromise of the host means compromise of the stack anyway. If those assumptions hold, internal TLS does not change the main risk. ## When proxy termination is enough ### 1. App and proxy run on the same host If Nginx is proxying to 127.0.0.1 on port 3000, adding TLS on that hop rarely helps. Anyone who can sniff loopback traffic on the machine already has host-level access, at which point your problem is not plain HTTP on localhost. ### 2. Containers are on a private Docker network you control If Traefik and the app talk over a private bridge network and the app service is not published directly to the internet, that is still a trusted local path for most self-hosted setups. ### 3. You want simpler app configuration Many apps become easier to run when they only need to [trust proxy headers](https://servercompass.app/features/trust-proxy-headers) and speak plain HTTP internally. Internal TLS means managing certificates, trust stores, health checks, and often awkward redirect settings. On a single VPS, simplicity is a security feature because fewer moving parts means fewer broken renewals, fewer bad redirects, and fewer mystery 502s. ## When internal TLS is worth it ### 1. Traffic crosses hosts The moment proxy and app are on different machines, the question changes. Examples: - Load balancer on one VPS, app on another - Reverse proxy in one Docker host, services on a second host - Proxy talking across a cloud VPC or provider [private network](https://servercompass.app/blog/connect-apps-private-network) Now the traffic is crossing infrastructure you may partially trust, but not completely. Internal TLS becomes reasonable and often recommended. ### 2. You operate on a shared or semi-trusted network If traffic traverses a network where other workloads or teams can exist, encrypting every hop is the safer default. ### 3. Compliance requires encryption in transit everywhere Some environments care less about your threat model and more about policy language. If the requirement says every hop must be encrypted, then internal TLS is not optional even if the traffic stays inside your private address space. ### 4. You want mutual TLS between services For service-to-service identity, mutual TLS is doing more than encryption. It is proving who the caller is. That matters in larger or zero-trust architectures, but it is usually overkill for one VPS with one proxy and one app. ## The hidden costs of internal TLS on one VPS People often frame extra encryption as purely additive. In practice, it adds operational failure modes. Common ones: - The app redirects HTTP to HTTPS internally and creates a loop behind the proxy. - The proxy validates a backend certificate signed by a CA the container does not trust. - Health checks fail because the backend now expects SNI or a specific Host header. - Cert renewal works at the edge, but backend certs quietly expire. - Local debugging gets harder because a simple curl to localhost no longer works without extra TLS flags. None of these make your stack more secure if the traffic never crossed a meaningful boundary in the first place. ## A safe baseline for most VPS deployments For the majority of [self-hosted apps](https://servercompass.app/blog/best-self-hosted-apps-vps) on one VPS, this is the sensible setup: ### Nginx on the host ~~~nginx server { listen 443 ssl http2; server_name app.example.com; ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ~~~ ### Traefik with Docker ~~~yaml services: traefik: image: traefik:v3 ports: - 80:80 - 443:443 networks: - edge app: image: myapp:latest expose: - 3000 networks: - edge labels: - traefik.enable=true - traefik.http.routers.app.rule=Host("app.example.com") - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 networks: edge: ~~~ In both cases, the backend stays plain HTTP and unexposed. ## When people choose internal TLS for the wrong reason A common argument is more encryption is always better. That is too shallow. Security decisions should reduce actual risk, not just increase ceremony. If an attacker already has root on your VPS or access to your private Docker network namespace, your real failure is host compromise, not lack of TLS on loopback traffic. At that point, disk encryption, patching discipline, least-privilege containers, network exposure control, and secret handling matter more than backend HTTPS on the same box. ## Decision rule you can use quickly Use this: ### Proxy-only TLS is usually enough when: - proxy and app are on the same VPS, - backend ports are not public, - traffic stays on localhost or a private Docker network, - no policy requires hop-by-hop encryption. ### Add internal TLS when: - traffic crosses hosts, - the network path is not fully trusted, - compliance requires it, - or you need service identity with mutual TLS. If you are unsure, map the actual packet path. That usually makes the answer obvious. ## Internal links - Guide: [SSL Certificates on VPS](/guide/ssl-certificates) - Guide: [Traefik SSL on VPS](/guide/traefik-ssl) - Guide: [Nginx Reverse Proxy for Node.js](/guide/nginx-reverse-proxy) - Guide: [How to Run Multiple Apps on One VPS Without Port Collisions](/guide/multi-app-vps-port-collisions) - ServerCompass: [Deploy apps behind a managed reverse proxy flow](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) ## Closing principle Do not ask how many TLS layers can I add. Ask where trust stops. On a single VPS, edge termination at the reverse proxy is usually the right answer. When the traffic leaves that trusted boundary, the case for internal TLS becomes real. Until then, keep the backend private, keep the topology simple, and avoid inventing complexity that does not materially improve your risk profile. --- ## Why Coolify Deploys Are Slow (And How to Make Them Vercel-Fast) Source: https://deploytovps.com/blog/guide/coolify-deploy-speed-tuning Published: 2026-05-05 Tags: coolify, deploy-speed, vps-sizing, build-cache Coolify deploys feel slow because the defaults favour simplicity over speed. Three causes — undersized VPS, no build cache, wrong build strategy — and the fixes. "My Coolify deploy takes 4 minutes; on Vercel the same app deployed in 30 seconds." This complaint is real, and the fix isn't switching back to Vercel. It's recognising that Coolify gives you the levers to be fast — they just aren't on by default. Slow deploys on Coolify almost always trace to one of three causes: under-sized VPS, no build cache, or the wrong build strategy for the app. Here's how to diagnose and fix each. ## First: measure where the time goes Before optimising, find out where the seconds actually go. Coolify's deploy log shows phase timings. Typical breakdown for a slow deploy: ``` Pulling source from git 5s Building Docker image 180s Pushing image to local registry 20s Pulling image on target server 8s Stopping old container 5s Starting new container 12s Health check 20s ───────────────────────────────── Total 250s ``` The build phase dominates. That's the lever to pull. ## Cause 1: Under-sized VPS Building Docker images is CPU-and-IO bound. A $5 VPS with 1 vCPU and 1 GB RAM will absolutely take 4+ minutes for a Next.js or Rails build. The cheap test: watch CPU and memory during a deploy. ```bash # Run during a deploy top -b -n 1 | head -20 free -m df -h /var/lib/docker ``` If CPU is pinned at 100% for the full build, your CPU is the bottleneck. If swap is being touched, RAM is the bottleneck. For most modern web apps, the deploy-friendly sweet spot is: - **2-4 vCPUs** (parallel build steps actually parallelise) - **4-8 GB RAM** (so npm install / pip install don't swap) - **NVMe storage** (HDD doubles or triples build time) Going from a $5 VPS to a $20 VPS often cuts deploy time by 60% — and you save more in your time than you spend on the bigger box. ## Cause 2: No build cache By default, Coolify rebuilds Docker images from scratch every deploy. If your Dockerfile starts with `RUN npm install`, every deploy re-downloads every package. Three fixes: ### Fix A: Better Dockerfile layering Make sure dependencies are installed *before* you copy your source code. The lockfile rarely changes, so the dependency layer caches well: ```dockerfile # Bad — rebuilds deps on every code change COPY . . RUN npm install RUN npm run build # Good — deps cached separately from code COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build ``` This single change can take a 3-minute build down to 30 seconds when only your source code changed. ### Fix B: BuildKit cache mounts If you're on a recent Docker (BuildKit enabled), use cache mounts for package managers: ```dockerfile # syntax=docker/dockerfile:1.4 FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build ``` The `--mount=type=cache` line keeps npm's download cache on the host between builds. First build is normal speed; subsequent builds skip re-downloads even if the lockfile changed. Same pattern works for pip, apt, cargo, go modules, and pnpm. ### Fix C: Coolify's "Build Pack" cache In Coolify → Service → Build, set "Build Pack" appropriately. The "Nixpacks" pack is usually slower than a hand-written Dockerfile because it does broader detection. If you have a working Dockerfile, set Build Pack to "Dockerfile" and point at it. ## Cause 3: Wrong build strategy Some apps don't need to be rebuilt every deploy. ### Static sites — pre-build and ship If your app is a Next.js export, an Astro build, a Hugo site, etc., the artifact is static. Build once locally or in CI, push to a registry, and have Coolify pull the prebuilt image. Coolify becomes a deployer, not a builder. ```yaml # docker-compose for Coolify services: app: image: ghcr.io/yourorg/yourapp:v1.2.3 # No build: section. Coolify just pulls. ``` Deploy time drops from "build + push + deploy" to "pull + deploy" — usually 20-30 seconds total. ### Multi-stage builds If your final image is small but your build needs heavy tooling (Webpack, Maven, Gradle, etc.), use multi-stage: ```dockerfile FROM node:20 AS builder WORKDIR /app COPY . . RUN npm ci && npm run build FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"] ``` The runtime image is small, and Coolify pushes/pulls less data per deploy. ### Skip rebuilds for CSS-only changes If you're touching only CSS or templates and your tooling supports HMR or hot reload, you don't always need a full container rebuild. Set up a volume mount for the assets directory and `docker compose restart` the service instead of redeploying. This is a development-mode optimisation; keep production deploys properly atomic. ## The CI pipeline pattern For teams shipping multiple times per day, the right pattern isn't "make Coolify build faster." It's: 1. **CI builds and pushes** the image to a registry (GitHub Container Registry, Hetzner registry, etc.). 2. **Coolify pulls and deploys** by image tag, no build phase at all. Deploys become 20-30 second pull-and-restart events. CI can take its time and use bigger machines. ``` git push → GitHub Actions → docker build + push → webhook to Coolify → docker pull + restart ``` Coolify supports this via "Deploy from registry" mode plus a webhook trigger. ## When the problem is the app, not Coolify Some apps are slow to build no matter what. Java/Maven projects with hundreds of dependencies, Rails apps with asset pipeline compilation, large monorepos. For these, the answer isn't tuning Coolify — it's: - **Caching the build artifact** so deploys are pull-only. - **Splitting the monorepo** so only the changed service rebuilds. - **Pre-warming dependencies** in a base image you rebuild weekly. The deploy speed feedback loop in your dev workflow matters. A 4-minute deploy makes you batch changes; a 30-second deploy makes you ship as you go. The latter is almost always better for product velocity. ## Internal links - Tutorial: [Self-Host Coolify on a VPS](/self-host/coolify) - Guide: [VPS Sizing for Production Apps](/guide/vps-sizing) - Guide: [GitHub Actions to VPS](/guide/github-actions-vps) - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - ServerCompass: [Pre-configured Coolify templates](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) ## Closing principle Coolify deploy speed is a tuning problem, not a tool limitation. The default config favours simplicity over speed; the production-ready config is faster than most managed PaaS for everything except the very first deploy. Spend an hour on Dockerfile layering, build cache, and (if needed) a CI-to-registry pipeline, and you'll match Vercel-speed deploys on hardware you control. --- ## Microweber + Dokploy: The 2-Minute Deploy Debugging Checklist Source: https://deploytovps.com/blog/guide/microweber-dokploy-deploy-checklist Published: 2026-05-05 Tags: dokploy, microweber, deploy-checklist, self-host A practical pre-deploy and post-failure checklist for self-hosting Microweber via Dokploy. Catches 80% of common breakage. Microweber is a PHP-based site builder. Dokploy is a self-hosted PaaS that runs Docker workloads. The two together — Microweber deployed via Dokploy on a VPS — is a practical stack for self-hosters who want a CMS without subscribing to one. It's also one of the more common deploy-debugging conversations on subreddits like r/selfhosted. The fixes follow a pattern. This is the 2-3 minute pre-deploy and post-failure checklist that catches the bulk of breakage. ## The pre-deploy checklist Run through these *before* you push the deploy button. Each takes seconds; together they catch ~80% of failures. ### 1. Confirm the database is reachable ```bash docker compose exec dokploy-postgres pg_isready -U postgres # or, if Postgres is host-installed: sudo -u postgres psql -c '\l' ``` Microweber expects to connect to a database on first run. If the DB is unreachable, the install hangs without a useful error. ### 2. Check disk free ```bash df -h /var df -h / ``` Microweber's `userfiles/` and Dokploy's image cache are both volume-backed. A 95%-full `/var` produces deploys that succeed silently and crash on first request when the volume hits 100%. Need at least 1GB free for a small Microweber install with reasonable headroom. ### 3. Pull image hash, not just `:latest` In your `docker-compose.yml` for Microweber: ```yaml microweber: image: microweber/microweber:1.4.0 # not :latest ``` Pinning prevents the "it worked yesterday" problem when upstream pushes a breaking change. `latest` is a deploy-debugging accelerant. ### 4. Verify the reverse proxy port mapping Dokploy fronts services with Traefik. Mismatched container/service ports cause silent 502s. In Dokploy → Service → Network, confirm: - Container port matches the port the app listens on inside the container (Microweber: 80). - Domain is set, with SSL enabled if you're past initial install. If you see "502 Bad Gateway" after a successful deploy, this is almost always the cause. ### 5. PHP memory and upload limits Microweber defaults trip on real-world content uploads. Add these env vars to the service: ``` PHP_MEMORY_LIMIT=512M PHP_POST_MAX_SIZE=64M PHP_UPLOAD_MAX_FILESIZE=64M PHP_MAX_EXECUTION_TIME=180 ``` Without them, "deploy succeeded but admin login times out" is a common bug. PHP-FPM hits the default `max_execution_time=30s` on a slow first-load. ### 6. Persistent volumes mounted In your service definition, confirm: ```yaml volumes: - microweber_userfiles:/var/www/html/userfiles - microweber_cache:/var/www/html/storage ``` Without these, a redeploy wipes uploaded media and rebuilt cache. Deploy 1 looks great. Deploy 2 looks empty. If your "site disappeared after I clicked redeploy" search led you here — this is almost certainly why. ## When deploy fails — the diagnosis tree ### "Service starts then exits" ```bash docker logs 2>&1 | tail -50 ``` Two common patterns: - `Connection refused (database)` → DB not ready when Microweber starts. Add a `depends_on: condition: service_healthy` clause and a healthcheck on the Postgres/MySQL service. - `Permission denied: /var/www/html/storage` → Volume mounted but with wrong UID. Either chown the volume directory on the host, or set `user: "33:33"` (www-data) in the compose file. ### "502 Bad Gateway" Almost always Traefik can't reach the container. ```bash docker compose exec dokploy-traefik wget -qO- http://microweber:80/ ``` If this fails inside the proxy network, the container isn't on the proxy network. Add the network explicitly: ```yaml networks: - dokploy-network networks: dokploy-network: external: true ``` ### "Login screen loads but admin times out" PHP execution timeout. Set `PHP_MAX_EXECUTION_TIME=180` in env, restart container. If still timing out, check Postgres logs for slow queries. Microweber's first-time admin login runs migrations. On a small VPS with no swap, this can OOM. ### "Site loads but no styles / 404 on /assets/" Microweber writes asset URLs based on the install URL. If you installed at `http://1.2.3.4` and now front it with `https://example.com`, the cached assets point to the IP. Fix: clear `storage/cache` either via the admin UI or: ```bash docker compose exec microweber rm -rf /var/www/html/storage/cache docker compose restart microweber ``` ### "Deploy succeeds but a previous version still shows" Browser cache, or Cloudflare cache if you're behind it. ```bash # Bust Cloudflare cache for the site: # Cloudflare dashboard → Caching → Purge Everything (or by URL) ``` Local browsers: Cmd+Shift+R / Ctrl+Shift+F5. ## The 2-3 minute rollback If a deploy goes wrong and you need to roll back fast: ```bash # In Dokploy: # Service → Deployments → click the previous deployment → "Redeploy" ``` Dokploy keeps the last N deployment images locally. If the previous deploy isn't visible, fall back to: ```bash docker images | grep microweber docker tag microweber/microweber:rollback # Update compose to use :rollback, redeploy. ``` Always test that you *can* roll back before you need to. Spinning up a deploy, then immediately [rolling back](https://servercompass.app/docs/rollback-deployments) to the previous, is a 5-minute drill that pays for itself the first time you need it for real. ## Internal links - Tutorial: [Set up Traefik with SSL on a VPS](/guide/traefik-ssl) - Guide: [Multiple Domains on One VPS](/guide/multiple-domains-vps) - Guide: [VPS Backup Strategy](/guide/backup-strategy) - ServerCompass: [One-click Microweber + Dokploy templates](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Run Multiple Apps on One VPS Without Port Collisions Source: https://deploytovps.com/blog/guide/multi-app-vps-port-collisions Published: 2026-05-05 Tags: ports, reverse-proxy, multi-app, vps, caddy The reverse proxy pattern explained for new self-hosters. Why port 80 only fits one app, and how Caddy/Traefik/nginx solve it. If you're new to self-hosting, the first multi-app moment is jarring. You've successfully got one app running on port 80. You install a second one. It also wants port 80. Two services can't bind the same port on the same IP. Now what? The answer is "use a reverse proxy" — but new self-hosters often don't see how the pieces fit, and the explanations available online tend to assume they already know. Here's the explicit version. ## What's actually happening A "port" is a number, 1-65535, that an OS uses to route incoming connections to a specific process. When you run `docker run -p 80:80 myapp`, the kernel binds port 80 to that container. The next process that tries to bind 80 fails with "address already in use." This is a feature, not a bug. Without it, two apps could intercept each other's traffic. The way you run multiple HTTP services on one machine is: put one program on port 80 and 443 — the *reverse proxy* — and have it route incoming requests to many backends running on different (internal) ports. ## The simplest mental model ``` Browser --→ Port 80/443 (Traefik / Caddy / nginx) --→ Backend on internal port 3001 \ → Backend on internal port 3002 \ → Backend on internal port 3003 ``` Only one process holds 80/443. Backends listen on internal ports that aren't exposed to the public internet — they only need to be reachable by the proxy. The proxy decides which backend to route to based on the request's `Host` header (`api.example.com` → backend A, `app.example.com` → backend B). ## The three popular choices ### Caddy Easiest learning curve. Ships with automatic HTTPS via Let's Encrypt out of the box. Configuration is a `Caddyfile`: ``` api.example.com { reverse_proxy localhost:3001 } app.example.com { reverse_proxy localhost:3002 } ``` That's the whole config. SSL, redirects, and certificate renewal are automatic. ### Traefik Discovers services via Docker labels. Most-loved option for Docker-based stacks because new services configure themselves: ```yaml services: myapp: image: myapp:latest labels: - traefik.enable=true - traefik.http.routers.myapp.rule=Host(`app.example.com`) - traefik.http.routers.myapp.entrypoints=websecure - traefik.http.routers.myapp.tls.certresolver=letsencrypt - traefik.http.services.myapp.loadbalancer.server.port=3000 ``` Add labels, redeploy, Traefik notices and starts routing. Great for many small services. ### nginx The classic choice. Manual configuration. More verbose, but battle-tested and infinitely tunable. Use it if you want maximum control or already know nginx. ```nginx server { listen 443 ssl http2; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` ## A worked example You want to host: - A blog at `blog.example.com` - An API at `api.example.com` - A dashboard at `dashboard.example.com` All on one VPS. ### Step 1: Pick internal ports Assign each backend a free internal port. Convention: start at 3000 and increment. - Blog: 3001 - API: 3002 - Dashboard: 3003 Run each service bound *only to localhost*: ```yaml services: blog: image: ghost:latest ports: - "127.0.0.1:3001:2368" # only localhost can reach it ``` `127.0.0.1:3001:2368` exposes container port 2368 (Ghost's default) to localhost on host port 3001. Outside the box can't reach it. ### Step 2: Reverse proxy Run Caddy (easiest) on 80/443: ``` blog.example.com { reverse_proxy localhost:3001 } api.example.com { reverse_proxy localhost:3002 } dashboard.example.com { reverse_proxy localhost:3003 } ``` Done. Caddy gets certificates from Let's Encrypt automatically and routes by `Host` header. ### Step 3: DNS Point three A records (or one A + two CNAMEs) at your VPS IP: ``` blog.example.com. A 1.2.3.4 api.example.com. A 1.2.3.4 dashboard.example.com. A 1.2.3.4 ``` Wait for DNS propagation (a few minutes to an hour). ### Step 4: Firewall Only ports 22 (SSH) and 80/443 (HTTP/HTTPS) should be open on the public network: ```bash sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable ``` Internal ports (3001-3003) are bound to localhost only — invisible from the public internet. ## Common mistakes ### "I exposed the backend port too" If you wrote `ports: - "3001:2368"` instead of `127.0.0.1:3001:2368`, the backend is reachable on the public IP at `:3001`. Not what you want. Fix: rebind to localhost only. Force traffic through the proxy. ### "I have two reverse proxies fighting over port 80" This happens when you install Caddy and also leave nginx running. Pick one. ```bash sudo systemctl stop nginx sudo systemctl disable nginx ``` ### "Traefik routes to the wrong container after a redeploy" Almost always a Docker network issue. Traefik must be on the same network as the backend container — and that network must be `external: true` in compose, otherwise compose recreates it with a new internal name. See [the CORS-from-network-drift writeup](/guide/cors-docker-network-drift) for the deeper version of this bug. ### "I get a 502 even though the backend is running" The proxy is talking to the wrong port. Check that `loadbalancer.server.port` (Traefik) or `proxy_pass` (nginx) matches the *container's* internal listen port, not the host port mapping. ## Internal links - Tutorial: [Set up Traefik with SSL on a VPS](/guide/traefik-ssl) - Guide: [Multiple Domains on One VPS](/guide/multiple-domains-vps) - Guide: [VPS Security Baseline](/guide/vps-security) - Guide: [Why Docker CORS Errors Keep Coming Back](/guide/cors-docker-network-drift) - ServerCompass: [Pre-configured multi-app VPS templates](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) ## TL;DR One reverse proxy on 80/443. Backends on localhost-only ports. DNS points everything at the VPS. Firewall blocks everything except 22/80/443. That's the entire pattern. Once you've set it up once, adding a fourth or fifth app is trivial — and the port-collision anxiety goes away for good. --- ## Stable Tailscale on Linux: A Post-Install Stability and Backup Routine Source: https://deploytovps.com/blog/guide/tailscale-linux-stability-backup Published: 2026-05-05 Tags: tailscale, linux, healthcheck, backup, self-hosting Auto-start, healthcheck, backup channel, and notification routine to make a Tailscale node you can actually trust to stay reachable. You set up Tailscale on a Linux Mint box (or Ubuntu, Debian, or any Linux desktop), got it connected, and now you need it to *stay* working. Reliably. Across reboots, power events, and the occasional flaky Wi-Fi hiccup. This is the practical post-install routine. It costs about 30 minutes once and saves you from "Tailscale was down again" tickets later. ## What "stable" actually means Three failure modes you want to prevent: 1. **Tailscale daemon crashes silently.** You don't notice until you can't SSH in. 2. **Network change breaks the tunnel.** New Wi-Fi, DHCP renewal, sleep/wake — connection drops and doesn't recover cleanly. 3. **Boot order race.** Machine reboots, Tailscale starts before the network is ready, fails, doesn't retry. A stable Tailscale setup defends against all three. ## Step 1: Confirm the daemon is set to auto-start ```bash systemctl is-enabled tailscaled systemctl is-active tailscaled ``` Both should say `enabled` and `active`. If either is `disabled`: ```bash sudo systemctl enable --now tailscaled ``` The `--now` makes it start immediately *and* on every boot. ## Step 2: Confirm the connection is set to auto-up The daemon being up isn't the same as the *connection* being up. After reboot, Tailscale needs the auth token to be remembered. ```bash sudo tailscale up --reset --authkey=tskey-auth-XXXXXX # only if you need to re-auth ``` Once authenticated, the credentials persist. Test by rebooting: ```bash sudo systemctl reboot # Wait for it to come back, then: tailscale status ``` If status shows the device as connected without manual intervention, you're set. For headless / always-on machines, generate an *ephemeral key* with the `--ssh` flag if you want SSH-via-Tailscale, or a regular auth key. Pre-generate from the Tailscale admin console so you can re-auth without your dev laptop. ## Step 3: Set the daemon to depend on network being ready By default the unit file is fine on Mint/Ubuntu, but if you have any custom networking (Wi-Fi roaming, VPN-on-VPN, etc.), edit the override to wait properly: ```bash sudo systemctl edit tailscaled ``` Add: ```ini [Unit] After=network-online.target Wants=network-online.target ``` Then: ```bash sudo systemctl enable systemd-networkd-wait-online.service sudo systemctl daemon-reload ``` This makes Tailscale's daemon wait for an IP before starting. Eliminates the "boot too fast" race. ## Step 4: Add a healthcheck Even with the above, occasional failures happen. A small healthcheck cron makes the system self-healing: ```bash sudo tee /usr/local/bin/tailscale-healthcheck.sh > /dev/null <<'EOF' #!/usr/bin/env bash # Verify Tailscale is up. If not, attempt restart. set -e if ! tailscale status >/dev/null 2>&1; then logger -t tailscale-healthcheck "tailscale status failed; restarting daemon" systemctl restart tailscaled sleep 5 tailscale up fi # Also verify a known peer is reachable if ! tailscale ping -c 1 -t 5s your-known-peer-name >/dev/null 2>&1; then logger -t tailscale-healthcheck "ping to peer failed; bouncing connection" tailscale down sleep 2 tailscale up fi EOF sudo chmod +x /usr/local/bin/tailscale-healthcheck.sh ``` Run it every 5 minutes via systemd timer or cron: ```bash sudo crontab -e # Add: */5 * * * * /usr/local/bin/tailscale-healthcheck.sh ``` The peer-ping line is optional but worth it — it catches "daemon thinks it's up but the tunnel is wedged" cases that tunnel restarts fix. ## Step 5: A backup channel for "Tailscale itself is broken" The first time you can't SSH in because Tailscale is unhealthy and SSH-on-the-public-Internet is locked down, you'll wish you had an out-of-band path. Options: - **Old-school SSH on a non-standard public port**, behind fail2ban and key-only auth, available only when needed. - **A second VPN** (WireGuard) configured but inactive. Activate via console if needed. - **Provider console / KVM** (if you're on a VPS that offers it). Most providers do — check before you need it. Pick one. You don't need it daily. You need it once. ## Step 6: A backup routine If the box is running anything stateful (notes, projects, code), back it up. The minimum viable backup: ```bash # /usr/local/bin/daily-backup.sh #!/usr/bin/env bash set -e DEST="/var/backups/$(date +%Y%m%d)" mkdir -p "$DEST" # Home dirs (skip caches and node_modules) rsync -a --delete \ --exclude='.cache' --exclude='node_modules' --exclude='.npm' \ /home/ "$DEST/home/" # Common app data [ -d /var/lib/postgresql ] && tar -czf "$DEST/postgres.tar.gz" /var/lib/postgresql 2>/dev/null [ -d /var/lib/docker/volumes ] && tar -czf "$DEST/docker-volumes.tar.gz" /var/lib/docker/volumes 2>/dev/null # Sync to remote rsync -a "$DEST/" backup-server:/backups/$(hostname)/$(date +%Y%m%d)/ || true # Keep last 7 daily, last 4 weekly find /var/backups -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \; ``` Schedule daily at 03:00: ```bash sudo crontab -e # Add: 0 3 * * * /usr/local/bin/daily-backup.sh >> /var/log/backup.log 2>&1 ``` Run it manually first to confirm the rsync target works before you trust the cron. The script gracefully handles missing directories so you don't need to customise per-machine. ## Step 7: Notification when Tailscale is down For anything you depend on, set up a "this machine is offline" notification. The easiest version: have another machine ping it via Tailscale every 5 minutes and alert you if it fails three times in a row. ```bash # On a different always-on machine */5 * * * * tailscale ping -c 1 -t 5s your-mint-box.tailnet || \ /usr/local/bin/notify-me.sh "Mint box offline" ``` The `notify-me.sh` can be a curl to ntfy.sh, a Slack webhook, a Pushover call — whatever you check. ## Quick recap ``` Auto-start daemon ✓ systemctl enable --now tailscaled Persist auth ✓ sudo tailscale up after first time, no logout Network-ready dependency ✓ systemd override After=network-online.target Healthcheck cron ✓ /usr/local/bin/tailscale-healthcheck.sh every 5 min Backup channel ✓ Out-of-band SSH or provider console Daily backups ✓ rsync to backup-server, 7-day retention External monitor ✓ Another node pings + alerts ``` Each step is small. Together they make a Tailscale node you can trust to stay reachable. ## Internal links - Tutorial: [Set up WireGuard as a Tailscale backup](/self-host/wireguard) - Guide: [VPS Backup Strategy](/guide/backup-strategy) - Guide: [SSH Hardening Baseline](/guide/vps-security) - ServerCompass: [Self-host monitoring tools to watch your nodes](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## Why Docker CORS Errors Keep Coming Back: The Proxy Network Drift Problem Source: https://deploytovps.com/blog/guide/cors-docker-network-drift Published: 2026-05-04 Tags: cors, docker, docker-compose, traefik, troubleshooting Recurring CORS errors in your Docker stack? They usually trace to Docker network attachment, not your CORS headers. Here's how to fix it permanently. CORS errors that come back a week after you "fixed" them are rarely a CORS problem. Nine times out of ten, the actual culprit is Docker network attachment — your reverse proxy is talking to a different network than your backend, and the connection silently re-routes through paths that mangle headers or fail entirely. This post walks through how proxy-network drift happens, how to recognise it, and how to make your stack immune to it. ## The symptom You see one of these in the browser console: ``` Access to fetch at 'https://api.example.com/users' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. ``` You add the right CORS middleware. You check the preflight. Everything works in `curl`. Two days later it breaks again. Or it works on staging and fails on production. Or it fails only after a `docker compose up`. If that pattern sounds familiar, the headers aren't the problem. The network is. ## Why network drift causes phantom CORS errors When [Docker Compose](https://servercompass.app/features/docker-compose-editor) creates a network, it does so per project. If your `traefik` service is in one project and your `backend` service is in another (a common pattern when each app has its own compose file), the proxy needs to be explicitly attached to every backend's network — otherwise the request hits the proxy, the proxy can't reach the backend container, and you get a 502 or a redirect that the browser interprets as a CORS failure. The trickier case: when you redeploy a backend, Docker may rebuild the network with a new gateway IP. If Traefik is using the network by name (good) it reconnects automatically. But if it's using the network by ID (or attached at start time without `external: true`), the proxy is now on a stale network and routes traffic to the previous container — which no longer exists. The browser sees the failed request as a CORS issue because no headers come back at all. ## How to confirm it From the host, exec into the proxy container and try to reach the backend: ```bash docker compose exec traefik wget -qO- http://backend:8080/health ``` If that hangs or returns "bad address," the network attachment is wrong. CORS headers can't help you — the request never reaches your app. Cross-check by listing networks: ```bash docker network ls docker network inspect traefik-public ``` Look at the `Containers` block. Every service that needs to be reachable through the proxy must be listed there. ## The permanent fix Move to a single, externally-declared shared network and reference it everywhere. ### Step 1: Create the network once, outside any compose file ```bash docker network create traefik-public ``` This network now exists independently of any project. Compose won't recreate it, rename it, or change its ID. ### Step 2: Reference it as `external` in every compose file In `docker-compose.yml` for the proxy: ```yaml services: traefik: image: traefik:v3.0 networks: - traefik-public # ... rest of config networks: traefik-public: external: true ``` In `docker-compose.yml` for each backend: ```yaml services: backend: image: my-backend:latest networks: - traefik-public - default labels: - traefik.enable=true - traefik.http.routers.backend.rule=Host(`api.example.com`) - traefik.http.services.backend.loadbalancer.server.port=8080 networks: traefik-public: external: true default: ``` The backend stays on its own `default` network for service-to-service traffic, but is also reachable through `traefik-public`. ### Step 3: Verify before declaring victory ```bash docker network inspect traefik-public --format '{{range .Containers}}{{.Name}} {{end}}' ``` Both `traefik` and `backend` should appear. If a service is missing, your compose file isn't actually attaching it. ## Common gotchas **Forgetting `external: true`.** Without it, Compose treats the network as project-scoped and creates a fresh one with a project prefix. Your services end up on `myproject_traefik-public` instead of `traefik-public`, and the proxy can't see them. **Using `network_mode: host` for one service.** Host networking bypasses Docker's network entirely, so labels and discovery break silently. **Recreating the proxy without re-attaching backends.** If Traefik rebuilds and the backend network was created with `--internal`, the proxy may not be allowed to attach. Watch `docker compose logs traefik` for "network not found" warnings. **Misconfigured `loadbalancer.server.port`.** Traefik routes to the wrong port → empty response → browser reports CORS. Double-check the port matches what the container actually listens on (not what you mapped to the host). ## When it really is CORS After ruling out networking, then check headers. The minimum you need on the backend response: ``` Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true ``` For preflight (`OPTIONS`) requests, the backend must return `200` or `204` with these headers — not delegate to the proxy. If you're using Traefik to inject CORS headers, make sure the middleware is attached to the router and not the service: ```yaml labels: - traefik.http.middlewares.cors.headers.accesscontrolalloworiginlist=https://app.example.com - traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS - traefik.http.middlewares.cors.headers.accesscontrolallowheaders=Content-Type,Authorization - traefik.http.middlewares.cors.headers.accesscontrolallowcredentials=true - traefik.http.routers.backend.middlewares=cors ``` But again — if the network is wrong, no amount of header tuning will help. Fix the network first. ## Internal links - Tutorial: [Set up Traefik with SSL on a VPS](/guide/traefik-ssl) - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Guide: [Multiple Domains on One VPS](/guide/multiple-domains-vps) - ServerCompass: [Pre-configured Traefik + Docker stacks](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## Do You Need a Domain Before Setting Up a Mail Server? (Yes — Here's Why) Source: https://deploytovps.com/blog/guide/mail-server-domain-prerequisites Published: 2026-05-04 Tags: mail-server, self-hosted, domain, dns, deliverability Self-hosting email? You need the domain before you spin up the server. Here's the order of operations: domain, DNS, then mail server install. Short answer: **yes**. Set up the domain first, then DNS, then the mail server. Doing it in any other order will burn your IP reputation before your first email goes out. Here's why the order matters and what to actually do. ![Mail-server DNS prerequisites checklist covering PTR, MX, SPF, DKIM, and DMARC before first send](https://assets.stoicsoft.com/posts/deploytovps/guide/mail-server-domain-prerequisites/dns-prerequisites-checklist.png) ## Why "just spin up the server first" fails Mail isn't like a web app. With HTTP, you can run on an IP address while you sort out DNS later — browsers don't care. With email, every receiving server (Gmail, Outlook, Fastmail, corporate Exchange) checks who you claim to be against DNS records before it accepts a single message. If you start sending before DNS is in place, three things happen — fast: 1. **PTR / reverse DNS fails.** Receivers see your IP, look up the PTR, and find a generic hostname like `vps-12345.provider.com`. Most filters either reject or quarantine on this alone. 2. **No SPF, DKIM, or DMARC records.** Receivers can't verify your messages. They get marked spam, often silently. 3. **Your IP gets flagged.** Spamhaus, Barracuda, and similar blocklists watch for new IPs sending unauthenticated mail. One bad day and you're on a list that takes weeks to get off. By the time you "fix it later," your IP has a bad reputation and even correct configuration won't earn deliverability back quickly. ## The right order ### Step 1: Buy the domain Cheap registrar is fine — Namecheap, Porkbun, Cloudflare Registrar all work. The only requirement: you must be able to manage DNS records. If your registrar bundles a locked-in DNS panel that doesn't let you add MX, TXT, and SRV records, switch nameservers to Cloudflare or another DNS provider before doing anything else. ### Step 2: Verify you can set a PTR record on your VPS Before anything else, confirm with your VPS provider that you can set a custom reverse DNS (PTR) record on your IPv4 (and IPv6 if you have one). Most reputable providers — Hetzner, OVH, Vultr, DigitalOcean, Linode — let you do this from the control panel. If the provider blocks PTR changes or hasn't released your IP from a known "VPS pool" range, **stop here**. Pick a different provider. You can't run mail without a clean PTR. Set the PTR to your planned mail server hostname: ``` 123.45.67.89 → mail.example.com ``` The reverse must match the forward — `mail.example.com` must resolve back to `123.45.67.89`. ### Step 3: Add the DNS records Add these to your domain's DNS zone before installing the mail server: ``` mail.example.com. A 123.45.67.89 example.com. MX 10 mail.example.com. example.com. TXT "v=spf1 mx ~all" _dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" ``` DKIM has to wait until the mail server generates its keypair (Step 5). ### Step 4: Install the mail server Now (and only now) install your mail stack. Common choices: - **Mailcow** — Docker-based, full-featured, easiest "self-host everything" option - **Mail-in-a-Box** — Single-script install, opinionated, great for one mailbox - **Stalwart** — Newer, single binary, lower memory - **Postfix + Dovecot + Rspamd** — Manual, maximum control For most people building a personal or small-team mail server, Mailcow or Mail-in-a-Box save weeks. ### Step 5: Add the DKIM record After install, the mail server gives you a DKIM public key. Add it as a TXT record: ``` mail._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..." ``` The selector (`mail` here) is set by your mail server's config. Match it exactly. ### Step 6: Test before sending real mail Use mail-tester.com or learndmarc.com: 1. Send an email from your new server to the address they give you 2. They score your setup out of 10 3. Anything below 9 means there's a config issue — fix it before sending real mail Also test: ```bash # Forward DNS dig mail.example.com +short # Reverse DNS — must match dig -x 123.45.67.89 +short # SPF dig TXT example.com +short # DKIM dig TXT mail._domainkey.example.com +short # DMARC dig TXT _dmarc.example.com +short ``` All five must return the expected values before you trust the setup. ## Special case: subdomain for mail only A common pattern is to use a subdomain (`mail.example.com` or `mx.example.com`) for the mail server, separate from your web server. This is fine and recommended — it lets you put your website on Cloudflare or a CDN without proxying mail (which Cloudflare won't do anyway). The MX record points to the mail subdomain: ``` example.com. MX 10 mail.example.com. mail.example.com. A 123.45.67.89 ``` You still need SPF, DKIM, and DMARC on the **bare domain** (`example.com`), because that's the From: domain on outbound mail. ## What if you already have a domain in use elsewhere? If your domain is already serving a website on a different host, you can still add mail without disrupting anything. The MX record points to a separate IP, and SPF/DKIM/DMARC are TXT records that won't conflict with anything else. Just make sure the existing SPF record (if any) is updated to include your new mail server's IP. Two SPF records on the same domain is a common mistake — there must be exactly one TXT record starting with `v=spf1`. ## When you really shouldn't self-host mail If you're sending more than a few hundred emails a day, or any of those are transactional (signup confirmations, password resets, receipts), use a transactional provider — Postmark, Resend, AWS SES — and only self-host inbound. Outbound deliverability at scale is a full-time problem and your IP reputation will lag the big providers' for months even with everything configured perfectly. For personal mail, a small team, or a side project, self-hosting works fine — as long as you do the prerequisites in the right order. ## Internal links - Guide: [VPS Security Baseline](/guide/vps-security) - Guide: [SSL Certificates with Let's Encrypt](/guide/ssl-certificates) - Tutorial: [Self-Host Mailcow on a VPS](/self-host/mailcow) - ServerCompass: [Mail server templates with PTR-clean providers](https://servercompass.app/templates?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## How to Deploy Astro to a VPS (Static + SSR Options) Source: https://deploytovps.com/blog/deploy/astro Published: 2026-03-06 Tags: astro, docker, vps, static, ssr Deploy Astro sites to a VPS. Static output with Nginx, or SSR mode with Node.js. Both with HTTPS and a clean update workflow. You are going to deploy an **Astro site** to a VPS: - **Static mode**: pre-built HTML served by Nginx (fastest, simplest) - **SSR mode**: Node.js server for dynamic content - HTTPS with automatic certificates - Cache headers optimized for each mode Choose static for blogs, docs, and marketing sites. Choose SSR when you need authentication, personalization, or API routes. ## What you will have at the end - `https://your-domain.com` serving your Astro site - Optimized caching for static assets - A deploy workflow under 30 seconds ## Step 1: Choose your output mode Check your `astro.config.mjs`: ```javascript // Static (default) - generates HTML files export default defineConfig({ output: 'static', }) // SSR - requires a Node.js server export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), }) // Hybrid - static by default, opt-in SSR per page export default defineConfig({ output: 'hybrid', adapter: node({ mode: 'standalone' }), }) ``` For SSR/hybrid, install the Node adapter: ```bash npx astro add node ``` ## Step 2: Create the Dockerfile ### Static Mode Dockerfile ```dockerfile FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` Create `nginx.conf`: ```nginx server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; # Cache static assets aggressively location /_astro/ { expires 1y; add_header Cache-Control "public, immutable"; } # Cache other static files location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 30d; add_header Cache-Control "public"; } # SPA fallback for client-side routing location / { try_files $uri $uri/ $uri.html /index.html; } # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; } ``` ### SSR Mode Dockerfile ```dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=4321 COPY --from=build /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ USER node EXPOSE 4321 CMD ["node", "./dist/server/entry.mjs"] ``` ## Step 3: Set up the VPS directory ```bash mkdir -p ~/apps/astro-site cd ~/apps/astro-site git clone https://github.com/you/your-astro-site.git . ``` ## Step 4: Create docker-compose.yml ### Static Mode Compose ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped site: build: . restart: unless-stopped labels: - traefik.enable=true - traefik.http.routers.site.rule=Host(`your-domain.com`) - traefik.http.routers.site.entrypoints=websecure - traefik.http.routers.site.tls=true - traefik.http.routers.site.tls.certresolver=letsencrypt - traefik.http.services.site.loadbalancer.server.port=80 volumes: letsencrypt: ``` ### SSR Mode Compose ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped site: build: . restart: unless-stopped environment: - HOST=0.0.0.0 - PORT=4321 labels: - traefik.enable=true - traefik.http.routers.site.rule=Host(`your-domain.com`) - traefik.http.routers.site.entrypoints=websecure - traefik.http.routers.site.tls=true - traefik.http.routers.site.tls.certresolver=letsencrypt - traefik.http.services.site.loadbalancer.server.port=4321 volumes: letsencrypt: ``` ## Step 5: Deploy ```bash docker compose up -d --build ``` Verify deployment: ```bash curl -I https://your-domain.com ``` Check cache headers are working: ```bash curl -I https://your-domain.com/_astro/some-file.css # Should show: Cache-Control: public, immutable ``` ## Step 6: Optimize performance For static sites, add Traefik compression: ```yaml site: build: . labels: - traefik.enable=true - traefik.http.routers.site.rule=Host(`your-domain.com`) - traefik.http.routers.site.middlewares=compress - traefik.http.middlewares.compress.compress=true # ... other labels ``` For SSR, add health checks: ```yaml site: build: . healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:4321/"] interval: 30s timeout: 10s retries: 3 ``` Update workflow: ```bash git pull docker compose up -d --build ``` ## Troubleshooting ### Build fails with memory errors Astro builds can use significant memory. On small VPS instances: ```bash # Add swap if needed sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile # Or build locally and push the image docker build -t your-registry/astro-site:latest . docker push your-registry/astro-site:latest ``` ### 404 errors on page refresh (static mode) Your nginx config needs the SPA fallback: ```nginx location / { try_files $uri $uri/ $uri.html /index.html; } ``` For Astro's default routing, `$uri.html` handles `/about` -> `/about.html`. ### SSR server crashes on startup Check your Astro config has the correct adapter: ```javascript import node from '@astrojs/node'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), }) ``` And verify the entry point in your Dockerfile: ```bash # Check what files were generated docker compose exec site ls -la dist/server/ ``` ### Environment variables not available For SSR, pass env vars through Docker: ```yaml site: build: . environment: - DATABASE_URL=${DATABASE_URL} - API_KEY=${API_KEY} ``` Or use `.env` file: ```yaml site: build: . env_file: - .env ``` ## Internal links (recommended next reads) - Tutorial: [Deploy Next.js](/deploy/nextjs) - React alternative - Tutorial: [Deploy Docker App](/deploy/docker) - container basics - Comparison: [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) - Comparison: [Netlify Alternatives](https://deployhandbook.com/alternatives/netlify) - ServerCompass: [Deploy Astro sites](https://servercompass.app/tutorials/deploy-astro?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, [1FileTool](https://1filetool.com) is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device. --- ## How to Deploy a Bun App to a VPS (Docker + HTTPS) Source: https://deploytovps.com/blog/deploy/bun Published: 2026-03-06 Tags: bun, docker, vps, javascript, typescript Deploy Bun applications to a VPS with Docker. Leverage Bun's fast startup and low memory footprint for efficient server deployments. You are going to deploy a **Bun application** to a VPS: - Bun's native runtime (not Node.js) - small Docker images (Bun's binary is ~50MB) - fast cold starts - HTTPS with automatic certificates Bun is ideal for VPS deployments because it uses less memory and starts faster than Node.js. ## What you will have at the end - `https://your-domain.com` serving your Bun app - A Docker image under 100MB - Sub-second cold starts - A repeatable deploy workflow ## Step 1: Prepare your Bun project Your `package.json` should have a start script: ```json { "name": "my-bun-app", "scripts": { "start": "bun run src/index.ts", "build": "bun build src/index.ts --outdir ./dist --target bun" } } ``` For HTTP servers, make sure you bind to `0.0.0.0`: ```typescript // src/index.ts const server = Bun.serve({ port: 3000, hostname: "0.0.0.0", // Important: not "localhost" fetch(req) { return new Response("Hello from Bun!"); }, }); console.log(`Server running at http://${server.hostname}:${server.port}`); ``` ## Step 2: Create the Dockerfile Bun provides official Docker images. Create `Dockerfile`: ```dockerfile FROM oven/bun:1-alpine AS deps WORKDIR /app COPY package.json bun.lockb* ./ RUN bun install --frozen-lockfile FROM oven/bun:1-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN bun run build FROM oven/bun:1-alpine AS run WORKDIR /app ENV NODE_ENV=production # Copy only what's needed for production COPY --from=build /app/dist ./dist COPY --from=build /app/package.json ./ COPY --from=deps /app/node_modules ./node_modules USER bun EXPOSE 3000 CMD ["bun", "run", "dist/index.js"] ``` For simpler apps without a build step: ```dockerfile FROM oven/bun:1-alpine WORKDIR /app COPY package.json bun.lockb* ./ RUN bun install --frozen-lockfile --production COPY . . USER bun EXPOSE 3000 CMD ["bun", "run", "src/index.ts"] ``` ## Step 3: Set up the VPS directory SSH into your VPS: ```bash mkdir -p ~/apps/bun-app cd ~/apps/bun-app ``` Copy your project files or clone from git: ```bash git clone https://github.com/you/your-bun-app.git . ``` ## Step 4: Create docker-compose.yml with Traefik ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped app: build: . restart: unless-stopped environment: - NODE_ENV=production - PORT=3000 labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`your-domain.com`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls=true - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 volumes: letsencrypt: ``` Replace `your-domain.com` and `you@example.com`. ## Step 5: Deploy ```bash docker compose up -d --build ``` Check the logs: ```bash docker compose logs -f app ``` You should see Bun's startup message almost instantly. Verify HTTPS: ```bash curl -I https://your-domain.com ``` ## Step 6: Optimize for production Add health checks to your compose file: ```yaml app: build: . restart: unless-stopped healthcheck: test: ["CMD", "bun", "--eval", "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))"] interval: 30s timeout: 10s retries: 3 # ... labels ``` Add a health endpoint to your app: ```typescript const server = Bun.serve({ port: 3000, hostname: "0.0.0.0", fetch(req) { const url = new URL(req.url); if (url.pathname === "/health") { return new Response("OK", { status: 200 }); } return new Response("Hello from Bun!"); }, }); ``` Update workflow: ```bash git pull docker compose up -d --build ``` ## Troubleshooting ### Bun lockfile errors If you see `bun.lockb` issues: ```bash # Regenerate lockfile locally bun install # Or in Docker, remove --frozen-lockfile temporarily RUN bun install ``` Make sure `bun.lockb` is committed to git if using `--frozen-lockfile`. ### TypeScript files not found Bun can run TypeScript directly, but paths matter: ```bash # Check your entry point exists docker compose exec app ls -la src/ # Verify the CMD in Dockerfile matches your file structure ``` ### Connection refused on port 3000 Your Bun server must bind to `0.0.0.0`, not `localhost`: ```typescript // Wrong const server = Bun.serve({ port: 3000 }); // Correct const server = Bun.serve({ port: 3000, hostname: "0.0.0.0" }); ``` ### Memory usage higher than expected Bun is efficient, but check for leaks: ```bash docker stats # Profile memory in your app docker compose exec app bun --smol run src/index.ts ``` The `--smol` flag reduces memory usage at the cost of some performance. ## Internal links (recommended next reads) - Tutorial: [Deploy Hono](/deploy/hono) - Bun-native web framework - Tutorial: [Deploy Docker App](/deploy/docker) - container fundamentals - Comparison: [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) - ServerCompass: [Deploy Bun apps instantly](https://servercompass.app/tutorials/deploy-bun?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Deploy a Docker App to a VPS (From Image to HTTPS) Source: https://deploytovps.com/blog/deploy/docker Published: 2026-03-06 Tags: docker, vps, traefik, containers Deploy any Docker image to a VPS with HTTPS, persistent volumes, and a repeatable workflow. The foundation for everything else. You are going to deploy a Docker container to a VPS with **HTTPS and a reverse proxy**: - pull or build any Docker image - route traffic through Traefik - [automatic [SSL certificates](https://servercompass.app/docs/ssl-certificates)](https://servercompass.app/features/auto-ssl) - a deploy workflow that works for any containerized app This is the foundation. Once you understand this pattern, deploying any Docker image becomes the same three commands. ## What you will have at the end - `https://your-domain.com` serving your containerized app - Traefik handling SSL termination and routing - A pattern you can repeat for any Docker image ## Step 1: Install Docker on your VPS SSH into your server and run: ```bash sudo apt update && sudo apt upgrade -y curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` Verify the installation: ```bash docker --version docker compose version ``` ## Step 2: Create the app directory structure ```bash mkdir -p ~/apps/myapp cd ~/apps/myapp ``` Each app gets its own directory. This keeps configs isolated and makes debugging straightforward. ## Step 3: Create a docker-compose.yml with Traefik Create `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped app: image: nginx:alpine # Replace with your image restart: unless-stopped labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`your-domain.com`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls=true - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=80 volumes: letsencrypt: ``` Replace: - `your-domain.com` with your actual domain - `you@example.com` with your email for Let's Encrypt - `nginx:alpine` with your Docker image - Port `80` in loadbalancer with the port your app listens on ## Step 4: Add environment variables and volumes For apps that need configuration, create a `.env` file: ```bash # .env DATABASE_URL=postgres://user:pass@db:5432/myapp SECRET_KEY=your-secret-key-here NODE_ENV=production ``` Update your compose file to use it: ```yaml app: image: your-image:latest env_file: - .env volumes: - ./data:/app/data # Persist data outside container restart: unless-stopped labels: # ... traefik labels ``` ## Step 5: Deploy your application ```bash docker compose up -d ``` Check that everything is running: ```bash docker compose ps docker compose logs -f app ``` Visit `https://your-domain.com` - you should see your app with a valid SSL certificate. ## Step 6: Update workflow Your update loop is simple: ```bash docker compose pull # Get latest image docker compose up -d # Recreate with new image docker image prune -f # Clean old images ``` For images you build locally: ```bash docker compose up -d --build ``` ## Troubleshooting ### SSL certificate not issued The most common issues: - DNS not propagated yet. Check with `dig your-domain.com` - Ports 80/443 blocked by firewall. Open them: `sudo ufw allow 80,443/tcp` - Rate limited by Let's Encrypt. Check Traefik logs for the exact error ```bash docker logs $(docker ps --filter name=traefik -q) 2>&1 | grep -i acme ``` ### Container keeps restarting Check the logs for the actual error: ```bash docker compose logs app --tail 50 ``` Common causes: - Missing [environment variables](https://servercompass.app/features/env-vars-editor) - Port already in use - Insufficient memory (check with `free -h`) ### 502 Bad Gateway This means Traefik cannot reach your app: - Verify your app is listening on the port specified in `loadbalancer.server.port` - Check if the container is healthy: `docker compose ps` - Ensure the app binds to `0.0.0.0`, not `127.0.0.1` ### Permission denied on volumes Docker runs as root by default. If your app runs as a non-root user: ```bash sudo chown -R 1000:1000 ./data ``` Or specify the user in your compose file: ```yaml app: image: your-image user: "1000:1000" ``` ## Internal links (recommended next reads) - Tutorial: [Deploy with Docker Compose](/deploy/docker-compose) - multi-container setups - Tutorial: [Deploy Next.js](/deploy/nextjs) - framework-specific example - Comparison: [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) - ServerCompass: [One-click Docker deploys](https://servercompass.app/tutorials/deploy-docker?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## How to Deploy Docker Compose Apps to a VPS (Multi-Container Setup) Source: https://deploytovps.com/blog/deploy/docker-compose Published: 2026-03-06 Tags: docker-compose, docker, vps, traefik, postgres Deploy multi-container applications with Docker Compose: app, database, cache, and reverse proxy all wired together with persistent storage. You are going to deploy a **multi-container application** to a VPS: - app server, database, and cache in one stack - internal Docker networking (services talk to each other by name) - persistent volumes that survive container restarts - HTTPS and reverse proxy included This is the pattern for any real application that needs more than one service. ## What you will have at the end - `https://your-domain.com` serving your app - PostgreSQL database with persistent storage - Redis cache for sessions or queues - All services managed with `docker compose up -d` ## Step 1: Set up the directory structure ```bash mkdir -p ~/apps/mystack cd ~/apps/mystack mkdir -p data/postgres data/redis ``` Your structure will look like: ``` ~/apps/mystack/ ├── docker-compose.yml ├── .env └── data/ ├── postgres/ └── redis/ ``` ## Step 2: Create the environment file Create `.env` with your secrets: ```bash # Database POSTGRES_USER=myapp POSTGRES_PASSWORD=change-this-strong-password POSTGRES_DB=myapp_production # App DATABASE_URL=postgres://myapp:change-this-strong-password@db:5432/myapp_production REDIS_URL=redis://cache:6379 SECRET_KEY=generate-a-64-char-random-string NODE_ENV=production # Domain DOMAIN=your-domain.com ACME_EMAIL=you@example.com ``` Generate a strong secret: ```bash openssl rand -hex 32 ``` ## Step 3: Create the docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped networks: - web app: image: your-app-image:latest # Or use build: . restart: unless-stopped env_file: - .env depends_on: db: condition: service_healthy cache: condition: service_started labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`${DOMAIN}`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls=true - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 networks: - web - internal db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 networks: - internal cache: image: redis:7-alpine restart: unless-stopped command: redis-server --appendonly yes volumes: - ./data/redis:/data networks: - internal networks: web: external: false internal: external: false volumes: letsencrypt: ``` Key points: - `db` and `cache` are only on the `internal` network (not exposed to internet) - `app` is on both networks (can reach database AND be reached by Traefik) - Health checks ensure the app waits for the database ## Step 4: Configure database migrations Most apps need to run migrations before starting. Add an init service: ```yaml migrate: image: your-app-image:latest command: npm run migrate # Or your migration command env_file: - .env depends_on: db: condition: service_healthy networks: - internal restart: "no" ``` Run migrations before starting the app: ```bash docker compose run --rm migrate docker compose up -d app ``` ## Step 5: Deploy the stack ```bash # Pull all images docker compose pull # Start services in order docker compose up -d db cache docker compose up -d app # Check everything is running docker compose ps ``` Verify database connection: ```bash docker compose exec db psql -U myapp -d myapp_production -c "SELECT 1;" ``` ## Step 6: Backup and maintenance workflow Create a backup script at `~/apps/mystack/backup.sh`: ```bash #!/bin/bash set -e BACKUP_DIR=~/backups/mystack DATE=$(date +%Y%m%d_%H%M%S) mkdir -p $BACKUP_DIR # Backup PostgreSQL docker compose exec -T db pg_dump -U myapp myapp_production | gzip > $BACKUP_DIR/db_$DATE.sql.gz # Backup Redis (optional, RDB snapshot) docker compose exec -T cache redis-cli BGSAVE sleep 2 cp ./data/redis/dump.rdb $BACKUP_DIR/redis_$DATE.rdb # Keep last 7 days find $BACKUP_DIR -mtime +7 -delete echo "Backup completed: $DATE" ``` ```bash chmod +x backup.sh ./backup.sh ``` Add to cron for daily backups: ```bash crontab -e # Add: 0 3 * * * cd ~/apps/mystack && ./backup.sh >> ~/logs/backup.log 2>&1 ``` ## Troubleshooting ### Database connection refused The app cannot reach the database: ```bash # Check if db is running docker compose ps db # Check db logs docker compose logs db --tail 20 # Test connection from app container docker compose exec app sh -c "nc -zv db 5432" ``` Common causes: - Database not healthy yet (check health status) - Wrong credentials in `.env` - Network misconfiguration (both services must share a network) ### Container runs out of memory Check memory usage: ```bash docker stats --no-stream ``` If PostgreSQL is eating too much RAM, limit it: ```yaml db: image: postgres:16-alpine deploy: resources: limits: memory: 1G # ... rest of config ``` ### Data disappears after restart Volume paths must be correct: ```bash # Check volume contents ls -la ./data/postgres # Verify volume is mounted docker compose exec db df -h /var/lib/postgresql/data ``` If empty, the volume path in docker-compose.yml does not match where the app writes data. ### Services cannot communicate [Docker Compose](https://servercompass.app/features/docker-compose-editor) creates a default network, but explicit networks are safer: ```bash # List networks docker network ls # Inspect network docker network inspect mystack_internal # Check which containers are connected docker network inspect mystack_internal --format '{{range .Containers}}{{.Name}} {{end}}' ``` ## Internal links (recommended next reads) - Tutorial: [Deploy Docker App](/deploy/docker) - single container basics - Tutorial: [Self-Host Supabase](/self-host/supabase) - complex Compose example - Tutorial: [Deploy Laravel](/deploy/laravel) - PHP + database stack - Comparison: [Railway Alternatives](https://deployhandbook.com/alternatives/railway) - ServerCompass: [Manage Compose stacks visually](https://servercompass.app/tutorials/deploy-docker-compose?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## How to Deploy Hono to a VPS (Docker + HTTPS) Source: https://deploytovps.com/blog/deploy/hono Published: 2026-03-06 Tags: hono, bun, api, docker, vps Deploy Hono APIs to a VPS with Bun or Node.js. Fast, lightweight, and perfect for JSON APIs and microservices. You are going to deploy a **Hono application** to a VPS: - Hono on Bun (fastest) or Node.js (most compatible) - minimal Docker image - HTTPS with automatic SSL - ready for API workloads Hono is ultralight (~14KB) and fast. Combined with Bun, you get sub-millisecond routing and minimal resource usage. ## What you will have at the end - `https://api.your-domain.com` serving your Hono API - Docker image under 100MB - A clean deploy workflow ## Step 1: Set up your Hono project If starting fresh: ```bash bun create hono my-api cd my-api ``` Your `src/index.ts` should export or run a server: ```typescript import { Hono } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' const app = new Hono() app.use('*', logger()) app.use('*', cors()) app.get('/', (c) => c.json({ message: 'Hello from Hono!' })) app.get('/health', (c) => c.json({ status: 'ok' })) app.get('/api/users', (c) => { return c.json([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]) }) export default { port: process.env.PORT || 3000, fetch: app.fetch, } ``` Update `package.json`: ```json { "scripts": { "dev": "bun run --hot src/index.ts", "start": "bun run src/index.ts" } } ``` ## Step 2: Create the Dockerfile (Bun runtime) ```dockerfile FROM oven/bun:1-alpine AS deps WORKDIR /app COPY package.json bun.lockb* ./ RUN bun install --frozen-lockfile FROM oven/bun:1-alpine WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NODE_ENV=production ENV PORT=3000 USER bun EXPOSE 3000 CMD ["bun", "run", "src/index.ts"] ``` Alternative: Node.js runtime (if you need Node.js compatibility): ```dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --only=production FROM node:20-alpine WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NODE_ENV=production ENV PORT=3000 USER node EXPOSE 3000 CMD ["node", "--import", "tsx", "src/index.ts"] ``` For Node.js, add `tsx` as a dependency or compile TypeScript first. ## Step 3: Set up the VPS directory ```bash mkdir -p ~/apps/hono-api cd ~/apps/hono-api git clone https://github.com/you/your-hono-api.git . ``` ## Step 4: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped api: build: . restart: unless-stopped environment: - NODE_ENV=production - PORT=3000 - DATABASE_URL=${DATABASE_URL:-} healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 labels: - traefik.enable=true - traefik.http.routers.api.rule=Host(`api.your-domain.com`) - traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.tls=true - traefik.http.routers.api.tls.certresolver=letsencrypt - traefik.http.services.api.loadbalancer.server.port=3000 volumes: letsencrypt: ``` Replace `api.your-domain.com` with your domain. ## Step 5: Deploy ```bash docker compose up -d --build ``` Test your API: ```bash curl https://api.your-domain.com/ curl https://api.your-domain.com/api/users curl https://api.your-domain.com/health ``` ## Step 6: Add rate limiting and security Hono has built-in middleware. Update your app: ```typescript import { Hono } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { secureHeaders } from 'hono/secure-headers' import { rateLimiter } from 'hono-rate-limiter' const app = new Hono() // Security headers app.use('*', secureHeaders()) // Logging app.use('*', logger()) // CORS - configure for your frontend app.use('*', cors({ origin: ['https://your-domain.com'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE'], })) // Rate limiting const limiter = rateLimiter({ windowMs: 60 * 1000, // 1 minute limit: 100, // 100 requests per minute keyGenerator: (c) => c.req.header('x-forwarded-for') || 'unknown', }) app.use('/api/*', limiter) // Routes app.get('/health', (c) => c.json({ status: 'ok' })) // ... rest of your routes ``` Update workflow: ```bash git pull docker compose up -d --build ``` ## Troubleshooting ### Import errors with Bun Hono works natively with Bun, but check your imports: ```typescript // Correct for Bun import { Hono } from 'hono' // If using node_modules resolution // Make sure bun.lockb exists bun install ``` ### CORS errors from frontend Your CORS config must match your frontend domain exactly: ```typescript app.use('*', cors({ origin: ['https://your-frontend.com', 'http://localhost:3001'], credentials: true, })) ``` ### 502 Bad Gateway Hono must bind to the correct host. When using the default export pattern: ```typescript // This works export default { port: 3000, fetch: app.fetch, } // Or explicitly Bun.serve({ port: 3000, hostname: '0.0.0.0', fetch: app.fetch, }) ``` ### Memory leaks in production Check for unclosed connections or growing data structures: ```bash docker stats # Monitor over time watch -n 5 'docker stats --no-stream' ``` Hono itself is stateless. Leaks usually come from your code (database connections, caching, etc.). ## Internal links (recommended next reads) - Tutorial: [Deploy Bun App](/deploy/bun) - Bun fundamentals - Tutorial: [Deploy Docker Compose](/deploy/docker-compose) - add a database - Tutorial: [Deploy Next.js](/deploy/nextjs) - full-stack alternative - Comparison: [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) - ServerCompass: [Deploy Hono APIs](https://servercompass.app/tutorials/deploy-hono?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Deploy Laravel to a VPS (Docker + PHP-FPM + HTTPS) Source: https://deploytovps.com/blog/deploy/laravel Published: 2026-03-06 Tags: laravel, php, docker, vps, mysql Deploy Laravel applications to a VPS with PHP-FPM, Nginx, MySQL, and Redis. Production-ready with queues, scheduler, and HTTPS. You are going to deploy a **Laravel application** to a VPS: - PHP-FPM for request handling - Nginx as web server (static files + PHP proxy) - MySQL database with persistent storage - Redis for cache and queues - Queue worker and scheduler as separate containers - HTTPS with automatic certificates This is production-grade Laravel hosting on your own VPS. ## What you will have at the end - `https://your-domain.com` serving your Laravel app - Background queue processing - Scheduled tasks running via cron - Database backups ready to configure ## Step 1: Prepare your Laravel project Make sure your `.env.example` has all required variables: ```bash APP_NAME=MyApp APP_ENV=production APP_DEBUG=false APP_URL=https://your-domain.com DB_CONNECTION=mysql DB_HOST=db DB_PORT=3306 DB_DATABASE=laravel DB_USERNAME=laravel DB_PASSWORD= REDIS_HOST=redis REDIS_PORT=6379 CACHE_DRIVER=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redis ``` ## Step 2: Create the Dockerfile Create `Dockerfile`: ```dockerfile FROM php:8.3-fpm-alpine AS base # Install system dependencies RUN apk add --no-cache \ git \ curl \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ libzip-dev \ zip \ unzip \ mysql-client \ oniguruma-dev # Install PHP extensions RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip opcache # Install Redis extension RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ && pecl install redis \ && docker-php-ext-enable redis \ && apk del .build-deps # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html # Production stage FROM base AS production # Copy application COPY . . # Install dependencies (no dev) RUN composer install --no-dev --optimize-autoloader --no-interaction # Set permissions RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache # Optimize Laravel RUN php artisan config:cache \ && php artisan route:cache \ && php artisan view:cache USER www-data EXPOSE 9000 CMD ["php-fpm"] ``` Create `nginx.conf`: ```nginx server { listen 80; server_name _; root /var/www/html/public; index index.php; charset utf-8; # Gzip gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; # Static files location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 30d; add_header Cache-Control "public"; try_files $uri =404; } location / { try_files $uri $uri/ /index.php?$query_string; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } error_page 404 /index.php; location ~ \.php$ { fastcgi_pass app:9000; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } location ~ /\.(?!well-known).* { deny all; } } ``` ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped nginx: image: nginx:alpine restart: unless-stopped volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./public:/var/www/html/public:ro depends_on: - app labels: - traefik.enable=true - traefik.http.routers.laravel.rule=Host(`${APP_DOMAIN}`) - traefik.http.routers.laravel.entrypoints=websecure - traefik.http.routers.laravel.tls=true - traefik.http.routers.laravel.tls.certresolver=letsencrypt - traefik.http.services.laravel.loadbalancer.server.port=80 app: build: context: . target: production restart: unless-stopped env_file: - .env volumes: - ./storage:/var/www/html/storage depends_on: db: condition: service_healthy redis: condition: service_started queue: build: context: . target: production restart: unless-stopped command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600 env_file: - .env volumes: - ./storage:/var/www/html/storage depends_on: - app - redis scheduler: build: context: . target: production restart: unless-stopped command: sh -c "while true; do php artisan schedule:run --verbose; sleep 60; done" env_file: - .env volumes: - ./storage:/var/www/html/storage depends_on: - app db: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: ${DB_DATABASE} MYSQL_USER: ${DB_USERNAME} MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped command: redis-server --appendonly yes volumes: - redis_data:/data volumes: letsencrypt: mysql_data: redis_data: ``` ## Step 4: Set up environment Create `.env` on your VPS: ```bash APP_NAME=MyApp APP_ENV=production APP_KEY=base64:your-key-here APP_DEBUG=false APP_URL=https://your-domain.com APP_DOMAIN=your-domain.com DB_CONNECTION=mysql DB_HOST=db DB_PORT=3306 DB_DATABASE=laravel DB_USERNAME=laravel DB_PASSWORD=strong-password-here DB_ROOT_PASSWORD=root-password-here REDIS_HOST=redis REDIS_PORT=6379 CACHE_DRIVER=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redis ACME_EMAIL=you@example.com ``` Generate an app key locally and copy it: ```bash php artisan key:generate --show ``` ## Step 5: Deploy ```bash # Create storage directories with correct permissions mkdir -p storage/framework/{sessions,views,cache} mkdir -p storage/logs chmod -R 775 storage bootstrap/cache # Start the stack docker compose up -d --build # Run migrations docker compose exec app php artisan migrate --force # Clear caches (first deploy) docker compose exec app php artisan optimize:clear docker compose exec app php artisan optimize ``` Verify everything is running: ```bash docker compose ps docker compose logs -f app ``` ## Step 6: Maintenance commands Common artisan commands through Docker: ```bash # Run migrations docker compose exec app php artisan migrate # Clear all caches docker compose exec app php artisan optimize:clear # Rebuild caches docker compose exec app php artisan optimize # Tinker docker compose exec app php artisan tinker # Queue restart (after code changes) docker compose exec app php artisan queue:restart ``` Database backup script (`backup.sh`): ```bash #!/bin/bash DATE=$(date +%Y%m%d_%H%M%S) BACKUP_DIR=~/backups/laravel mkdir -p $BACKUP_DIR docker compose exec -T db mysqldump -u laravel -p'your-password' laravel | gzip > $BACKUP_DIR/db_$DATE.sql.gz find $BACKUP_DIR -mtime +7 -delete echo "Backup completed: $BACKUP_DIR/db_$DATE.sql.gz" ``` ## Troubleshooting ### Storage permission errors Laravel needs write access to storage: ```bash # Fix permissions docker compose exec app chown -R www-data:www-data /var/www/html/storage docker compose exec app chmod -R 775 /var/www/html/storage # Or from host sudo chown -R 82:82 storage # 82 is www-data in Alpine ``` ### Queue jobs not processing Check the queue worker logs: ```bash docker compose logs -f queue ``` Common issues: - Redis not connected (check REDIS_HOST) - Job class not found (run `composer dump-autoload`) - Supervisor not restarting after deploys (use `queue:restart`) ### MySQL connection refused Wait for the health check to pass: ```bash docker compose ps # Check db health status # Test connection manually docker compose exec db mysql -u laravel -p -e "SELECT 1;" ``` ### Slow first request after deploy Laravel caches are cold. Run optimization: ```bash docker compose exec app php artisan optimize docker compose exec app php artisan view:cache ``` ## Internal links (recommended next reads) - Tutorial: [Deploy Docker Compose](/deploy/docker-compose) - understand multi-container setups - Tutorial: [Self-Host Supabase](/self-host/supabase) - alternative database - Comparison: [Laravel Forge Alternatives](https://deployhandbook.com/alternatives/laravel-forge) - ServerCompass: [Deploy Laravel in one click](https://servercompass.app/tutorials/deploy-laravel?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, [1FileTool](https://1filetool.com) is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device. --- ## How to Deploy Next.js to a VPS (Docker + HTTPS) Source: https://deploytovps.com/blog/deploy/nextjs Published: 2026-03-06 Tags: nextjs, docker, vps, traefik A repeatable Next.js VPS deploy: Docker image, reverse proxy, HTTPS, and a clean update workflow you can reuse for every app. You are going to deploy Next.js to a VPS in a way that is **boring and repeatable**: - containerized build - reverse proxy in front - HTTPS on your domain - a deploy workflow you can run again in 2 minutes next time If you want "deploy now and worry later", use a PaaS. If you want to pay VPS pricing and still ship fast, this is the path. ## What you will have at the end - `https://your-domain.com` serving your Next.js app - `docker compose up -d` as your deployment primitive - a reverse proxy that can host multiple apps later ## Step 1: Update the server and install Docker On the VPS: ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` Install the Compose plugin (Ubuntu usually has it, but confirm): ```bash docker compose version ``` ## Step 2: Create the app directory ```bash mkdir -p ~/apps/nextjs-web cd ~/apps/nextjs-web ``` You want each app isolated in its own directory. That makes backups, debugging, and migrations obvious. ## Step 3: Add a production Dockerfile (simple and stable) Create `Dockerfile`: ```dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./ RUN npm ci || npm install FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-alpine AS run WORKDIR /app ENV NODE_ENV=production COPY --from=build /app ./ EXPOSE 3000 CMD [\"npm\", \"start\"] ``` Notes: - This assumes your app has `build` and `start` scripts. - If you use `output: 'standalone'`, adjust the runtime stage to copy only the standalone output. The above works as a general default. ## Step 4: Add a reverse proxy (HTTPS) you can reuse You can do this with Nginx or Traefik. I recommend Traefik when you want to host multiple apps later, because the routing rules live in labels and you avoid hand-editing Nginx blocks forever. Create `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped nextjs: build: . restart: unless-stopped labels: - traefik.enable=true - traefik.http.routers.nextjs.rule=Host(`your-domain.com`) - traefik.http.routers.nextjs.entrypoints=websecure - traefik.http.routers.nextjs.tls=true - traefik.http.routers.nextjs.tls.certresolver=letsencrypt - traefik.http.services.nextjs.loadbalancer.server.port=3000 volumes: letsencrypt: ``` Replace: - `your-domain.com` - `you@example.com` ## Step 5: Deploy Copy your app code into this folder (git clone is fine), then run: ```bash docker compose up -d --build ``` If DNS is correct, Traefik should issue a certificate and your app should come up. ## Step 6: Updates (your repeatable deploy loop) Your deploy loop should be boring: ```bash git pull docker compose up -d --build ``` That is it. If you want safer deploys, add: - health checks - a staging domain (or subdomain) - a rollback strategy (keep last known-good image tag) ## Troubleshooting (the errors you will actually hit) ### "Certificate not issued" or HTTPS never works - confirm the domain A record points to the VPS IP - confirm ports 80 and 443 are open - check Traefik logs: ```bash docker logs -f $(docker ps --filter name=traefik -q) ``` ### "502 Bad Gateway" This usually means your Next.js container is not listening on port 3000. - confirm `npm start` runs a server - confirm `EXPOSE 3000` and Traefik service port match ### Deploys are slow or memory errors happen - build on the VPS is expensive - if you are using a small VPS, consider building on CI and pushing images ## Internal links (recommended next reads) - Comparison: `/alternatives/vercel` - Tutorial: `/self-host/supabase` - ServerCompass: https://servercompass.app/tutorials/deploy-nextjs?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta --- ## VPS Backup Strategy: Complete Guide Source: https://deploytovps.com/blog/guide/backup-strategy Published: 2026-03-06 Tags: backup, docker, vps, disaster-recovery, automation Implement reliable backups for your VPS including Docker volumes, databases, and application data with automated scheduling and offsite storage. A practical guide to backing up your VPS. Covers Docker volumes, databases, application files, and automated offsite backups. ## Overview A solid backup strategy follows the 3-2-1 rule: - **3** copies of your data - **2** different storage types - **1** offsite location This guide covers: 1. What to back up 2. Docker volume backups 3. Database backups 4. Automated scheduling 5. Offsite storage with restic 6. Testing and restoring ## What to back up | Data Type | Location | Priority | |-----------|----------|----------| | Database data | Docker volumes, /var/lib/postgresql | Critical | | User uploads | /var/www/uploads, Docker volumes | Critical | | Application config | docker-compose.yml, .env files | High | | [SSL certificates](https://servercompass.app/docs/ssl-certificates) | /etc/letsencrypt | Medium | | System config | /etc/nginx, custom configs | Medium | Do NOT back up: - Docker images (rebuild from registry) - node_modules, vendor directories (reinstall) - [System packages](https://servercompass.app/features/system-packages) (reinstall) - Log files (unless needed for compliance) ## Step 1: Create backup directories ```bash sudo mkdir -p /backups/{daily,weekly,monthly} sudo mkdir -p /backups/scripts sudo chown -R $USER:$USER /backups ``` ## Step 2: Docker volume backups ### List your volumes ```bash docker volume ls ``` ### Backup a single volume ```bash docker run --rm \ -v your_volume:/source:ro \ -v /backups/daily:/backup \ alpine tar czf /backup/your_volume-$(date +%Y%m%d).tar.gz -C /source . ``` ### Backup script for all volumes Create `/backups/scripts/backup-volumes.sh`: ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) # List of volumes to backup VOLUMES=( "app_postgres_data" "app_redis_data" "app_uploads" ) for VOLUME in "${VOLUMES[@]}"; do echo "Backing up $VOLUME..." docker run --rm \ -v "$VOLUME":/source:ro \ -v "$BACKUP_DIR":/backup \ alpine tar czf "/backup/${VOLUME}-${DATE}.tar.gz" -C /source . done # Clean up backups older than 7 days find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete echo "Backup completed: $(date)" ``` Make it executable: ```bash chmod +x /backups/scripts/backup-volumes.sh ``` ### Backup with container stopped (for consistency) For databases that need consistency: ```bash #!/bin/bash set -e COMPOSE_DIR="$HOME/apps/myapp" BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) cd "$COMPOSE_DIR" # Stop the app (keep database running for dump) docker compose stop app # Backup the volume docker run --rm \ -v myapp_data:/source:ro \ -v "$BACKUP_DIR":/backup \ alpine tar czf "/backup/myapp_data-${DATE}.tar.gz" -C /source . # Start the app docker compose start app ``` ## Step 3: Database backups ### PostgreSQL backup Create `/backups/scripts/backup-postgres.sh`: ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) CONTAINER="myapp-postgres-1" # Your postgres container name DB_NAME="myapp" DB_USER="postgres" # Create SQL dump docker exec "$CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_DIR/postgres-${DB_NAME}-${DATE}.sql.gz" # Clean up old backups find "$BACKUP_DIR" -name "postgres-*.sql.gz" -mtime +7 -delete echo "PostgreSQL backup completed: $(date)" ``` ### MySQL/MariaDB backup ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) CONTAINER="myapp-mysql-1" DB_NAME="myapp" DB_USER="root" DB_PASS="your_password" docker exec "$CONTAINER" mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip > "$BACKUP_DIR/mysql-${DB_NAME}-${DATE}.sql.gz" find "$BACKUP_DIR" -name "mysql-*.sql.gz" -mtime +7 -delete echo "MySQL backup completed: $(date)" ``` ### MongoDB backup ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) CONTAINER="myapp-mongo-1" DB_NAME="myapp" docker exec "$CONTAINER" mongodump --db "$DB_NAME" --archive --gzip > "$BACKUP_DIR/mongo-${DB_NAME}-${DATE}.gz" find "$BACKUP_DIR" -name "mongo-*.gz" -mtime +7 -delete echo "MongoDB backup completed: $(date)" ``` ## Step 4: Application file backups ### Backup uploaded files ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) UPLOADS_DIR="/var/www/myapp/uploads" tar czf "$BACKUP_DIR/uploads-${DATE}.tar.gz" -C "$UPLOADS_DIR" . find "$BACKUP_DIR" -name "uploads-*.tar.gz" -mtime +7 -delete ``` ### Backup configuration files ```bash #!/bin/bash set -e BACKUP_DIR="/backups/daily" DATE=$(date +%Y%m%d-%H%M%S) # Config files to backup tar czf "$BACKUP_DIR/config-${DATE}.tar.gz" \ /home/deploy/apps/*/docker-compose.yml \ /home/deploy/apps/*/.env \ /etc/nginx/sites-available \ /etc/letsencrypt find "$BACKUP_DIR" -name "config-*.tar.gz" -mtime +30 -delete ``` ## Step 5: Automated scheduling with cron Edit crontab: ```bash crontab -e ``` Add scheduled backups: ```bash # Daily backups at 3 AM 0 3 * * * /backups/scripts/backup-volumes.sh >> /var/log/backup.log 2>&1 0 3 * * * /backups/scripts/backup-postgres.sh >> /var/log/backup.log 2>&1 # Weekly backup rotation (Sunday at 4 AM) 0 4 * * 0 cp /backups/daily/*.tar.gz /backups/weekly/ # Monthly backup rotation (1st of month at 5 AM) 0 5 1 * * cp /backups/daily/*.tar.gz /backups/monthly/ # Clean weekly backups older than 30 days 0 6 * * 0 find /backups/weekly -name "*.tar.gz" -mtime +30 -delete # Clean monthly backups older than 365 days 0 6 1 * * find /backups/monthly -name "*.tar.gz" -mtime +365 -delete ``` ## Step 6: Offsite backups with restic Restic is a modern backup tool with encryption and deduplication. ### Install restic ```bash sudo apt install restic -y ``` ### Initialize a backup repository **Backblaze B2:** ```bash export B2_ACCOUNT_ID="your-account-id" export B2_ACCOUNT_KEY="your-account-key" restic -r b2:your-bucket-name:backups init ``` **AWS S3:** ```bash export AWS_ACCESS_KEY_ID="your-key" export AWS_SECRET_ACCESS_KEY="your-secret" restic -r s3:s3.amazonaws.com/your-bucket/backups init ``` **SFTP to another server:** ```bash restic -r sftp:user@backup-server:/backups init ``` ### Create a backup ```bash restic -r b2:your-bucket:backups backup /backups/daily ``` ### Automated offsite backup script Create `/backups/scripts/offsite-backup.sh`: ```bash #!/bin/bash set -e export B2_ACCOUNT_ID="your-account-id" export B2_ACCOUNT_KEY="your-account-key" export RESTIC_PASSWORD="your-encryption-password" export RESTIC_REPOSITORY="b2:your-bucket:backups" # Backup local backups directory restic backup /backups/daily # Backup Docker volumes directly for VOLUME in app_postgres_data app_uploads; do docker run --rm \ -v "$VOLUME":/data:ro \ -v /backups/scripts:/scripts:ro \ -e B2_ACCOUNT_ID \ -e B2_ACCOUNT_KEY \ -e RESTIC_PASSWORD \ -e RESTIC_REPOSITORY \ restic/restic backup /data --tag "$VOLUME" done # Prune old backups (keep 7 daily, 4 weekly, 12 monthly) restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune echo "Offsite backup completed: $(date)" ``` Add to cron: ```bash 0 4 * * * /backups/scripts/offsite-backup.sh >> /var/log/backup.log 2>&1 ``` ### Store credentials securely Create `/backups/.env`: ```bash B2_ACCOUNT_ID=your-account-id B2_ACCOUNT_KEY=your-account-key RESTIC_PASSWORD=your-encryption-password RESTIC_REPOSITORY=b2:your-bucket:backups ``` ```bash chmod 600 /backups/.env ``` Update script: ```bash #!/bin/bash set -e source /backups/.env export B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY # ... rest of script ``` ## Step 7: Verify and test backups ### List restic snapshots ```bash restic -r b2:your-bucket:backups snapshots ``` ### Test restore a file ```bash # Create a restore directory mkdir /tmp/restore-test # Restore latest snapshot restic -r b2:your-bucket:backups restore latest --target /tmp/restore-test # Verify files ls -la /tmp/restore-test ``` ### Test database restore ```bash # Create test database docker exec myapp-postgres-1 createdb -U postgres test_restore # Restore gunzip -c /backups/daily/postgres-myapp-latest.sql.gz | \ docker exec -i myapp-postgres-1 psql -U postgres test_restore # Verify docker exec myapp-postgres-1 psql -U postgres -d test_restore -c "\dt" # Cleanup docker exec myapp-postgres-1 dropdb -U postgres test_restore ``` ### Automated restore test (monthly) Create `/backups/scripts/test-restore.sh`: ```bash #!/bin/bash set -e TEST_DIR="/tmp/restore-test-$(date +%Y%m%d)" mkdir -p "$TEST_DIR" # Get latest backup LATEST=$(ls -t /backups/daily/postgres-myapp-*.sql.gz | head -1) # Test extraction gunzip -c "$LATEST" > "$TEST_DIR/test.sql" # Verify SQL is valid (basic check) head -50 "$TEST_DIR/test.sql" | grep -q "PostgreSQL database dump" if [ $? -eq 0 ]; then echo "Restore test PASSED: $(date)" >> /var/log/backup.log else echo "Restore test FAILED: $(date)" >> /var/log/backup.log # Send alert # curl -X POST -d "Backup restore test failed" https://your-webhook fi rm -rf "$TEST_DIR" ``` ## Disaster recovery plan Document your recovery process: ### 1. Provision new VPS ```bash # Create new VPS with same specs # Point DNS to new IP ``` ### 2. Install prerequisites ```bash curl -fsSL https://get.docker.com | sh sudo apt install restic -y ``` ### 3. Restore from offsite backup ```bash export RESTIC_REPOSITORY="b2:your-bucket:backups" export RESTIC_PASSWORD="your-encryption-password" # List snapshots restic snapshots # Restore to temp directory restic restore latest --target /tmp/restore ``` ### 4. Restore application ```bash # Copy docker-compose.yml cp /tmp/restore/config/docker-compose.yml ~/apps/myapp/ # Restore volumes docker volume create app_postgres_data docker run --rm \ -v app_postgres_data:/target \ -v /tmp/restore/daily:/backup \ alpine tar xzf /backup/app_postgres_data-latest.tar.gz -C /target # Start application cd ~/apps/myapp docker compose up -d ``` ### 5. Verify ```bash # Check application is working curl https://yourdomain.com # Check database docker exec myapp-postgres-1 psql -U postgres -c "\dt" ``` ## Backup monitoring ### Simple monitoring script Create `/backups/scripts/monitor-backups.sh`: ```bash #!/bin/bash BACKUP_DIR="/backups/daily" MAX_AGE_HOURS=25 ALERT_WEBHOOK="https://your-webhook-url" # Find backups older than threshold OLD_BACKUPS=$(find "$BACKUP_DIR" -name "*.tar.gz" -mmin +$((MAX_AGE_HOURS * 60)) | wc -l) TOTAL_BACKUPS=$(find "$BACKUP_DIR" -name "*.tar.gz" | wc -l) if [ "$OLD_BACKUPS" -eq "$TOTAL_BACKUPS" ]; then # All backups are old - alert! curl -X POST "$ALERT_WEBHOOK" \ -H "Content-Type: application/json" \ -d "{\"text\": \"Backup alert: No recent backups found on $(hostname)\"}" fi ``` Add to cron: ```bash 0 8 * * * /backups/scripts/monitor-backups.sh ``` ## Troubleshooting ### Backup script fails silently Check logs: ```bash tail -100 /var/log/backup.log ``` Test script manually: ```bash /backups/scripts/backup-volumes.sh ``` ### Restic connection timeout Check credentials and network: ```bash restic -r b2:your-bucket:backups snapshots ``` ### Disk space full Check usage: ```bash df -h /backups du -sh /backups/* ``` Clean old backups: ```bash find /backups/daily -name "*.tar.gz" -mtime +3 -delete ``` ### Database backup corrupted Verify backup integrity: ```bash gunzip -t /backups/daily/postgres-myapp-*.sql.gz ``` If corrupted, check [container logs](https://servercompass.app/docs/viewing-container-logs) during backup time: ```bash docker logs myapp-postgres-1 --since 24h ``` ## Where to go next **Tutorials:** - [VPS Security Hardening](/guide/vps-security) - [GitHub Actions Deploy to VPS](/guide/github-actions-vps) - [Self-Host Supabase](/self-host/supabase) **Comparisons:** - [Backup Storage Providers](https://deployhandbook.com/compare/backblaze-vs-s3) **ServerCompass:** - [Automated backup management](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, [1FileTool](https://1filetool.com) is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device. --- ## GitHub Actions Deploy to VPS: Complete CI/CD Guide Source: https://deploytovps.com/blog/guide/github-actions-vps Published: 2026-03-06 Tags: github-actions, ci-cd, deployment, vps, docker, automation Set up automated deployments from GitHub to your VPS using GitHub Actions with SSH, Docker, and zero-downtime strategies. Set up automated deployments from GitHub to your VPS. Push to main, and your changes go live automatically. ## Overview This guide covers three deployment strategies: 1. **SSH and pull** - Simple, good for small projects 2. **Docker image via registry** - Better for production 3. **Zero-downtime with health checks** - Best for critical apps Pick the one that matches your needs. ## Prerequisites on your VPS Ensure Docker is installed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER ``` Create a deploy user (recommended over using root): ```bash sudo adduser deploy sudo usermod -aG docker deploy sudo usermod -aG sudo deploy ``` ## Step 1: Set up SSH key authentication On your local machine, generate a deployment key: ```bash ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N "" ``` Copy the public key to your VPS: ```bash ssh-copy-id -i ~/.ssh/deploy_key.pub deploy@YOUR_VPS_IP ``` Test the connection: ```bash ssh -i ~/.ssh/deploy_key deploy@YOUR_VPS_IP "echo 'Connection successful'" ``` ## Step 2: Add secrets to GitHub Go to your repository on GitHub: 1. Settings > Secrets and variables > Actions 2. Add these repository secrets: | Secret Name | Value | |-------------|-------| | `VPS_HOST` | Your VPS IP address | | `VPS_USER` | `deploy` (or your deploy user) | | `VPS_SSH_KEY` | Contents of `~/.ssh/deploy_key` (private key) | | `VPS_PORT` | `22` (or your SSH port) | To copy the private key: ```bash cat ~/.ssh/deploy_key ``` Copy everything including the `-----BEGIN` and `-----END` lines. ## Strategy 1: SSH and pull (simplest) This strategy SSHes into your VPS, pulls the latest code, and rebuilds. Create `.github/workflows/deploy.yml`: ```yaml name: Deploy to VPS on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }} script: | cd ~/apps/your-app git pull origin main docker compose down docker compose up -d --build docker image prune -f ``` Pros: - Simple setup - No registry needed - Good for small projects Cons: - Build happens on VPS (uses VPS resources) - Downtime during rebuild - Slower for large builds ## Strategy 2: Docker image via registry (recommended) Build on GitHub, push to a registry, pull on VPS. ### Set up GitHub Container Registry Add another secret to your repository: | Secret Name | Value | |-------------|-------| | `GHCR_TOKEN` | Your GitHub Personal Access Token with `write:packages` scope | Or use the automatic `GITHUB_TOKEN` (simpler). ### Create the workflow Create `.github/workflows/deploy.yml`: ```yaml name: Build and Deploy on: push: branches: - main env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write outputs: image_tag: ${{ steps.meta.outputs.tags }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=sha,prefix= type=raw,value=latest - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max deploy: needs: build runs-on: ubuntu-latest steps: - name: Deploy to VPS uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }} script: | cd ~/apps/your-app # Pull the latest image docker pull ghcr.io/${{ github.repository }}:latest # Update the running container docker compose down docker compose up -d # Clean up old images docker image prune -f ``` Update your `docker-compose.yml` on the VPS to use the registry image: ```yaml services: app: image: ghcr.io/yourusername/your-repo:latest restart: unless-stopped # ... rest of your config ``` Log in to GHCR on your VPS (one-time setup): ```bash echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin ``` Pros: - Build happens on GitHub runners (free) - Faster deploys (just pull, no build) - Versioned images for rollbacks Cons: - More setup - Requires registry access on VPS ## Strategy 3: Zero-downtime deployment Use [Docker Compose](https://servercompass.app/features/docker-compose-editor) with health checks and rolling updates. ### Update docker-compose.yml ```yaml services: app: image: ghcr.io/yourusername/your-repo:latest restart: unless-stopped deploy: replicas: 2 update_config: parallelism: 1 delay: 10s failure_action: rollback rollback_config: parallelism: 1 delay: 10s healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 10s timeout: 5s retries: 3 start_period: 40s labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`yourdomain.com`) - traefik.http.routers.app.tls=true - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 ``` Add a health endpoint to your app: ```javascript // Express example app.get('/health', (req, res) => { res.status(200).json({ status: 'ok' }); }); ``` ### Update the workflow ```yaml name: Zero-Downtime Deploy on: push: branches: - main env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest cache-from: type=gha cache-to: type=gha,mode=max deploy: needs: build runs-on: ubuntu-latest steps: - name: Zero-downtime deploy uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }} script: | cd ~/apps/your-app # Pull new image docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} # Tag as latest docker tag ghcr.io/${{ github.repository }}:${{ github.sha }} \ ghcr.io/${{ github.repository }}:latest # Rolling update docker compose up -d --no-deps --scale app=2 app # Wait for health checks sleep 30 # Remove old containers docker compose up -d --no-deps --scale app=1 app # Cleanup docker image prune -f - name: Verify deployment uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }} script: | curl -f https://yourdomain.com/health || exit 1 ``` ## Running tests before deploy Add a test job: ```yaml jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm test - run: npm run lint build: needs: test # ... rest of build job ``` ## Environment-specific deploys Deploy to staging on PR, production on main: ```yaml name: Deploy on: push: branches: - main pull_request: branches: - main jobs: deploy-staging: if: github.event_name == 'pull_request' runs-on: ubuntu-latest environment: staging steps: - name: Deploy to staging uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.STAGING_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd ~/apps/your-app-staging docker compose pull docker compose up -d deploy-production: if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production steps: - name: Deploy to production uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd ~/apps/your-app docker compose pull docker compose up -d ``` ## Deployment notifications Add Slack or Discord notifications: ```yaml notify: needs: deploy runs-on: ubuntu-latest if: always() steps: - name: Notify on success if: needs.deploy.result == 'success' uses: slackapi/slack-github-action@v1.24.0 with: payload: | { "text": "Deployed ${{ github.repository }} to production", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Deployed to production*\nRepo: ${{ github.repository }}\nCommit: ${{ github.sha }}" } } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - name: Notify on failure if: needs.deploy.result == 'failure' uses: slackapi/slack-github-action@v1.24.0 with: payload: | { "text": "Deployment failed for ${{ github.repository }}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Deployment failed*\nRepo: ${{ github.repository }}\nCheck: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" } } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} ``` ## Rollback strategy Keep the previous image for quick rollbacks: ```bash # On VPS, before deploying docker tag ghcr.io/you/app:latest ghcr.io/you/app:previous # To rollback docker tag ghcr.io/you/app:previous ghcr.io/you/app:latest docker compose up -d ``` Or use specific commit SHA tags: ```bash # Rollback to specific version docker pull ghcr.io/you/app:abc1234 docker tag ghcr.io/you/app:abc1234 ghcr.io/you/app:latest docker compose up -d ``` ## Troubleshooting ### SSH connection refused - Verify the IP address is correct - Check SSH is running: `sudo systemctl status ssh` - Check firewall: `sudo ufw status` - Verify the key was added to authorized_keys ### Permission denied (publickey) - Ensure the private key in GitHub Secrets includes all lines - Check key permissions on VPS: `chmod 600 ~/.ssh/authorized_keys` - Verify the username matches ### Docker pull fails on VPS Log in to the registry: ```bash echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin ``` Check the image visibility (must be public or you need auth). ### Workflow never triggers - Check the branch name matches exactly - Verify the workflow file is in `.github/workflows/` - Check the YAML syntax is valid ### Build succeeds but deploy fails Check the SSH script output in [GitHub Actions](https://servercompass.app/tutorials/deploy-vps-github-actions-cicd) logs. Common issues: - Wrong directory path - Missing [environment variables](https://servercompass.app/features/env-vars-editor) - Docker Compose syntax errors ## Where to go next **Tutorials:** - [Deploy Next.js to VPS](/deploy/nextjs) - [VPS Security Hardening](/guide/vps-security) - [SSL Certificates on VPS](/guide/ssl-certificates) **Comparisons:** - [GitHub Actions vs GitLab CI](https://deployhandbook.com/compare/github-actions-vs-gitlab-ci) **ServerCompass:** - [Automated CI/CD setup](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## Multiple Domains on a Single VPS: Complete Guide Source: https://deploytovps.com/blog/guide/multiple-domains-vps Published: 2026-03-06 Tags: nginx, multiple-domains, vps, virtual-hosts, docker Host multiple websites and applications on one VPS using Nginx virtual hosts and Docker containers with proper SSL for each domain. Run multiple websites and applications on a single VPS. This guide covers both the traditional Nginx approach and the Docker/Traefik approach for containerized apps. ## Overview One VPS can host dozens of websites. The key is a reverse proxy that routes requests based on the domain name. You have two main options: 1. **Nginx virtual hosts** - Best for static sites and traditional apps 2. **Traefik with Docker** - Best for containerized applications This guide covers both approaches. ## DNS setup for all domains Before configuring your server, point all your domains to your VPS IP. For each domain, create an A record: | Type | Name | Value | |------|------|-------| | A | @ | YOUR_VPS_IP | | A | www | YOUR_VPS_IP | Wait for DNS propagation (usually 5-30 minutes, can take up to 48 hours). Verify with: ```bash dig +short yourdomain.com dig +short anotherdomain.com ``` Both should return your VPS IP. ## Option 1: Nginx virtual hosts This is the traditional approach. Good for static sites, PHP apps, and proxying to local services. ### Install Nginx ```bash sudo apt update sudo apt install nginx -y ``` ### Create directory structure ```bash sudo mkdir -p /var/www/site1.com/html sudo mkdir -p /var/www/site2.com/html sudo mkdir -p /var/www/site3.com/html ``` Set permissions: ```bash sudo chown -R $USER:$USER /var/www/site1.com/html sudo chown -R $USER:$USER /var/www/site2.com/html sudo chown -R $USER:$USER /var/www/site3.com/html ``` ### Create site configurations **Site 1 - Static website:** ```bash sudo nano /etc/nginx/sites-available/site1.com ``` ```nginx server { listen 80; server_name site1.com www.site1.com; root /var/www/site1.com/html; index index.html; location / { try_files $uri $uri/ =404; } } ``` **Site 2 - Node.js application:** ```bash sudo nano /etc/nginx/sites-available/site2.com ``` ```nginx server { listen 80; server_name site2.com www.site2.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` **Site 3 - Another Node.js application on a different port:** ```bash sudo nano /etc/nginx/sites-available/site3.com ``` ```nginx server { listen 80; server_name site3.com www.site3.com; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` ### Enable all sites ```bash sudo ln -s /etc/nginx/sites-available/site1.com /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/site2.com /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/site3.com /etc/nginx/sites-enabled/ ``` Remove the default site: ```bash sudo rm /etc/nginx/sites-enabled/default ``` Test and reload: ```bash sudo nginx -t sudo systemctl reload nginx ``` ### Add SSL to all domains Install Certbot: ```bash sudo apt install certbot python3-certbot-nginx -y ``` Get certificates for all domains at once: ```bash sudo certbot --nginx -d site1.com -d www.site1.com -d site2.com -d www.site2.com -d site3.com -d www.site3.com ``` Or one at a time: ```bash sudo certbot --nginx -d site1.com -d www.site1.com sudo certbot --nginx -d site2.com -d www.site2.com sudo certbot --nginx -d site3.com -d www.site3.com ``` Certbot will automatically update your Nginx configs to use HTTPS. ## Option 2: Docker with Traefik This is the modern approach for containerized applications. Traefik automatically discovers containers and routes traffic based on labels. ### Install Docker ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ### Create the Traefik network ```bash docker network create traefik-public ``` ### Create Traefik configuration ```bash mkdir -p ~/traefik cd ~/traefik ``` Create `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --api.dashboard=true - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=traefik-public - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt networks: - traefik-public restart: unless-stopped labels: - traefik.enable=true - traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`) - traefik.http.routers.dashboard.service=api@internal - traefik.http.routers.dashboard.tls=true - traefik.http.routers.dashboard.tls.certresolver=letsencrypt - traefik.http.routers.dashboard.middlewares=auth - traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz... volumes: letsencrypt: networks: traefik-public: external: true ``` Generate a password hash for the dashboard: ```bash sudo apt install apache2-utils -y htpasswd -nb admin your-secure-password ``` Replace the `auth.basicauth.users` value with the output. Start Traefik: ```bash docker compose up -d ``` ### Deploy applications with automatic SSL **App 1 - Next.js on site1.com:** ```bash mkdir -p ~/apps/site1 cd ~/apps/site1 ``` Create `docker-compose.yml`: ```yaml services: app: build: . restart: unless-stopped networks: - traefik-public labels: - traefik.enable=true - traefik.http.routers.site1.rule=Host(`site1.com`) || Host(`www.site1.com`) - traefik.http.routers.site1.entrypoints=websecure - traefik.http.routers.site1.tls=true - traefik.http.routers.site1.tls.certresolver=letsencrypt - traefik.http.services.site1.loadbalancer.server.port=3000 networks: traefik-public: external: true ``` **App 2 - Python Flask on site2.com:** ```bash mkdir -p ~/apps/site2 cd ~/apps/site2 ``` Create `docker-compose.yml`: ```yaml services: app: build: . restart: unless-stopped networks: - traefik-public labels: - traefik.enable=true - traefik.http.routers.site2.rule=Host(`site2.com`) || Host(`www.site2.com`) - traefik.http.routers.site2.entrypoints=websecure - traefik.http.routers.site2.tls=true - traefik.http.routers.site2.tls.certresolver=letsencrypt - traefik.http.services.site2.loadbalancer.server.port=5000 networks: traefik-public: external: true ``` **App 3 - Static site on site3.com:** ```bash mkdir -p ~/apps/site3 cd ~/apps/site3 ``` Create `docker-compose.yml`: ```yaml services: app: image: nginx:alpine restart: unless-stopped volumes: - ./html:/usr/share/nginx/html:ro networks: - traefik-public labels: - traefik.enable=true - traefik.http.routers.site3.rule=Host(`site3.com`) || Host(`www.site3.com`) - traefik.http.routers.site3.entrypoints=websecure - traefik.http.routers.site3.tls=true - traefik.http.routers.site3.tls.certresolver=letsencrypt - traefik.http.services.site3.loadbalancer.server.port=80 networks: traefik-public: external: true ``` Deploy each app: ```bash cd ~/apps/site1 && docker compose up -d cd ~/apps/site2 && docker compose up -d cd ~/apps/site3 && docker compose up -d ``` Traefik automatically detects the containers and configures routing and SSL. ## Managing multiple sites ### List all running containers ```bash docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" ``` ### View logs for a specific site ```bash cd ~/apps/site1 docker compose logs -f ``` ### Update a specific site ```bash cd ~/apps/site1 git pull docker compose up -d --build ``` ### Resource usage Monitor memory and CPU per container: ```bash docker stats ``` ### Check Traefik routing View the Traefik dashboard at `https://traefik.yourdomain.com` or check the API: ```bash curl -s http://localhost:8080/api/http/routers | jq ``` ## Subdomain routing You can also route subdomains to different apps. With Traefik labels: ```yaml labels: - traefik.http.routers.api.rule=Host(`api.yourdomain.com`) - traefik.http.routers.app.rule=Host(`app.yourdomain.com`) - traefik.http.routers.blog.rule=Host(`blog.yourdomain.com`) ``` With Nginx: ```nginx server { server_name api.yourdomain.com; location / { proxy_pass http://127.0.0.1:3001; } } server { server_name app.yourdomain.com; location / { proxy_pass http://127.0.0.1:3002; } } ``` ## Resource planning How many sites can you run on one VPS? | VPS Size | Static Sites | Node.js Apps | Full-stack Apps | |----------|--------------|--------------|-----------------| | 1GB RAM | 10-20 | 2-3 | 1 | | 2GB RAM | 20-50 | 4-6 | 2-3 | | 4GB RAM | 50-100 | 8-12 | 4-6 | | 8GB RAM | 100+ | 15-20 | 8-10 | These are estimates. Actual capacity depends on traffic and app complexity. ## Troubleshooting ### Domain shows wrong site Check the server_name (Nginx) or Host rule (Traefik) is unique for each site. For Nginx: ```bash grep -r "server_name" /etc/nginx/sites-enabled/ ``` For Traefik: ```bash docker inspect | grep -A5 "Labels" ``` ### SSL certificate not issued - Verify DNS is pointing to your VPS - Check that ports 80 and 443 are open - For Traefik, check logs: ```bash docker logs traefik 2>&1 | grep -i acme ``` ### Container not accessible Ensure the container is on the traefik-public network: ```bash docker network inspect traefik-public ``` ### Port conflicts If running apps directly (not in Docker), ensure each app uses a unique port: ```bash sudo ss -tlnp | grep LISTEN ``` ### Nginx config syntax error Test before reloading: ```bash sudo nginx -t ``` Check the specific error and line number in the output. ## Where to go next **Tutorials:** - [Nginx Reverse Proxy Guide](/guide/nginx-reverse-proxy) - [SSL Certificates on VPS](/guide/ssl-certificates) - [Deploy Next.js to VPS](/deploy/nextjs) **Comparisons:** - [VPS Providers Comparison](https://deployhandbook.com/compare/hetzner-vs-digitalocean) **ServerCompass:** - [Multi-domain deployment made simple](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## Nginx Reverse Proxy for Node.js: Complete VPS Guide Source: https://deploytovps.com/blog/guide/nginx-reverse-proxy Published: 2026-03-06 Tags: nginx, reverse-proxy, nodejs, vps, ssl Set up Nginx as a reverse proxy for Node.js applications with SSL, load balancing, and production-ready configuration. A practical guide to setting up Nginx as a reverse proxy for Node.js applications on a VPS. This is the foundation for hosting multiple apps on one server with proper SSL. ## Overview Nginx sits in front of your Node.js app. It handles: - SSL termination (HTTPS) - Static file serving - Request buffering - Load balancing (if you scale later) Your Node.js app stays on localhost:3000, and Nginx routes external traffic to it. ## Step 1: Install Nginx ```bash sudo apt update sudo apt install nginx -y ``` Verify it is running: ```bash sudo systemctl status nginx ``` Open your VPS IP in a browser. You should see the Nginx welcome page. ## Step 2: Configure the firewall Allow HTTP and HTTPS traffic: ```bash sudo ufw allow 'Nginx Full' sudo ufw enable sudo ufw status ``` ## Step 3: Create a reverse proxy configuration Remove the default site: ```bash sudo rm /etc/nginx/sites-enabled/default ``` Create a new configuration for your app: ```bash sudo nano /etc/nginx/sites-available/your-app ``` Add this configuration: ```nginx server { listen 80; server_name your-domain.com www.your-domain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` Enable the site: ```bash sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/ ``` ## Step 4: Test and reload Nginx Test the configuration: ```bash sudo nginx -t ``` If the test passes, reload: ```bash sudo systemctl reload nginx ``` ## Step 5: Add SSL with Let's Encrypt Install Certbot: ```bash sudo apt install certbot python3-certbot-nginx -y ``` Obtain and install the certificate: ```bash sudo certbot --nginx -d your-domain.com -d www.your-domain.com ``` Certbot will: - Obtain the certificate - Modify your Nginx config to use HTTPS - Set up auto-renewal Verify auto-renewal is working: ```bash sudo certbot renew --dry-run ``` ## Step 6: Production-ready configuration After Certbot runs, your config will be updated. Here is a production-ready version with additional optimizations: ```bash sudo nano /etc/nginx/sites-available/your-app ``` ```nginx # Redirect HTTP to HTTPS server { listen 80; server_name your-domain.com www.your-domain.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name your-domain.com www.your-domain.com; # SSL certificates (managed by Certbot) ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Gzip compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml; # Proxy settings location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } # Static files (if your app serves them from /public) location /static/ { alias /var/www/your-app/public/; expires 1y; add_header Cache-Control "public, immutable"; } } ``` Test and reload: ```bash sudo nginx -t && sudo systemctl reload nginx ``` ## Step 7: Configure your Node.js app Your Node.js app should trust the [proxy headers](https://servercompass.app/docs/trust-proxy-headers). In Express: ```javascript const express = require('express'); const app = express(); // Trust first proxy app.set('trust proxy', 1); app.get('/', (req, res) => { // req.ip will now be the real client IP res.send('Hello World'); }); app.listen(3000, '127.0.0.1', () => { console.log('Server running on port 3000'); }); ``` Binding to `127.0.0.1` ensures the app only accepts connections from localhost (through Nginx), not directly from the internet. ## Step 8: Keep your app running with PM2 Install PM2 globally: ```bash sudo npm install -g pm2 ``` Start your app: ```bash cd /var/www/your-app pm2 start npm --name "your-app" -- start ``` Save the process list and enable startup: ```bash pm2 save pm2 startup ``` Run the command that PM2 outputs to enable auto-start on boot. ## Multiple apps on one server Add another site configuration: ```bash sudo nano /etc/nginx/sites-available/another-app ``` ```nginx server { listen 80; server_name another-domain.com; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` Enable it and add SSL: ```bash sudo ln -s /etc/nginx/sites-available/another-app /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx sudo certbot --nginx -d another-domain.com ``` ## Load balancing (scaling up) If you need to run multiple instances of your app: ```nginx upstream nodejs_cluster { least_conn; server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; } server { listen 443 ssl http2; server_name your-domain.com; # SSL config... location / { proxy_pass http://nodejs_cluster; # ... other proxy settings } } ``` Start multiple instances with PM2: ```bash pm2 start app.js -i 3 --name "your-app" ``` ## Troubleshooting ### 502 Bad Gateway Your Node.js app is not running or not listening on the expected port. ```bash # Check if the app is running pm2 status # Check what is listening on port 3000 sudo ss -tlnp | grep 3000 # Check app logs pm2 logs your-app ``` ### 504 Gateway Timeout The request is taking too long. Increase timeouts in your Nginx config: ```nginx proxy_connect_timeout 300s; proxy_send_timeout 300s; proxy_read_timeout 300s; ``` ### SSL certificate not renewing Check the Certbot timer: ```bash sudo systemctl status certbot.timer ``` Manually test renewal: ```bash sudo certbot renew --dry-run ``` ### WebSocket connections failing Ensure your Nginx config includes the upgrade headers: ```nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; ``` ### Permission denied errors Check Nginx error logs: ```bash sudo tail -f /var/log/nginx/error.log ``` Ensure Nginx can read your [SSL certificates](https://servercompass.app/docs/ssl-certificates): ```bash sudo ls -la /etc/letsencrypt/live/your-domain.com/ ``` ## Where to go next **Tutorials:** - [Multiple Domains on VPS](/guide/multiple-domains-vps) - [SSL Certificates on VPS](/guide/ssl-certificates) - [Deploy Next.js to VPS](/deploy/nextjs) **Comparisons:** - [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) **ServerCompass:** - [Automated reverse proxy setup](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, [1FileTool](https://1filetool.com) is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device. --- ## SSL Certificates on VPS: Complete Let's Encrypt Guide Source: https://deploytovps.com/blog/guide/ssl-certificates Published: 2026-03-06 Tags: ssl, letsencrypt, https, certbot, vps, security Set up free SSL certificates on your VPS using Let's Encrypt with Certbot, Traefik, or Caddy. Includes auto-renewal and wildcard certificates. Get free [SSL certificates](https://servercompass.app/docs/ssl-certificates) for your VPS using Let's Encrypt. This guide covers multiple methods: Certbot with Nginx/Apache, Traefik, and Caddy. ## Overview Let's Encrypt provides free, automated SSL certificates. You have several options: | Method | Best For | Difficulty | |--------|----------|------------| | Certbot + Nginx | Traditional setups | Easy | | Certbot + Apache | PHP/WordPress | Easy | | Traefik | Docker containers | Medium | | Caddy | Automatic everything | Easiest | | Certbot standalone | No web server | Easy | ## Before you start Verify your domain points to your VPS: ```bash dig +short yourdomain.com ``` This should return your VPS IP address. Ensure ports 80 and 443 are open: ```bash sudo ufw allow 80 sudo ufw allow 443 sudo ufw status ``` ## Method 1: Certbot with Nginx ### Install Certbot ```bash sudo apt update sudo apt install certbot python3-certbot-nginx -y ``` ### Get the certificate ```bash sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com ``` Certbot will: 1. Verify you control the domain 2. Obtain the certificate 3. Configure Nginx to use HTTPS 4. Set up auto-renewal Follow the prompts: - Enter your email for renewal notices - Agree to terms of service - Choose whether to redirect HTTP to HTTPS (recommended: yes) ### Verify the configuration ```bash sudo nginx -t sudo systemctl reload nginx ``` Visit `https://yourdomain.com` - you should see a padlock. ### Verify auto-renewal ```bash sudo certbot renew --dry-run ``` Certbot installs a systemd timer that runs twice daily: ```bash sudo systemctl status certbot.timer ``` ## Method 2: Certbot with Apache ### Install Certbot ```bash sudo apt update sudo apt install certbot python3-certbot-apache -y ``` ### Get the certificate ```bash sudo certbot --apache -d yourdomain.com -d www.yourdomain.com ``` ### Verify ```bash sudo apachectl configtest sudo systemctl reload apache2 ``` ## Method 3: Certbot standalone (no web server) Use this when you do not have a web server running or want to manage certificates separately. ### Stop any service on port 80 ```bash sudo systemctl stop nginx # or apache2 ``` ### Get the certificate ```bash sudo certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com ``` Certificates are saved to: - Certificate: `/etc/letsencrypt/live/yourdomain.com/fullchain.pem` - Private key: `/etc/letsencrypt/live/yourdomain.com/privkey.pem` ### Restart your service ```bash sudo systemctl start nginx ``` ### Configure renewal hooks Since standalone needs port 80 free, add hooks: ```bash sudo nano /etc/letsencrypt/renewal/yourdomain.com.conf ``` Add at the end: ```ini [renewalparams] pre_hook = systemctl stop nginx post_hook = systemctl start nginx ``` ## Method 4: Traefik (Docker) Traefik handles SSL automatically for Docker containers. ### Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped app: image: your-app:latest labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`yourdomain.com`) - traefik.http.routers.app.tls=true - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 volumes: letsencrypt: ``` ### Start the stack ```bash docker compose up -d ``` Traefik automatically obtains and renews certificates for any container with the appropriate labels. ## Method 5: Caddy (automatic SSL) Caddy handles SSL with zero configuration. ### Install Caddy ```bash sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list sudo apt update sudo apt install caddy -y ``` ### Configure Caddy ```bash sudo nano /etc/caddy/Caddyfile ``` For a reverse proxy: ``` yourdomain.com { reverse_proxy localhost:3000 } anotherdomain.com { reverse_proxy localhost:3001 } ``` For a static site: ``` yourdomain.com { root * /var/www/html file_server } ``` ### Start Caddy ```bash sudo systemctl restart caddy ``` That is it. Caddy automatically obtains and renews certificates. ## Wildcard certificates Wildcard certificates cover all subdomains: `*.yourdomain.com` ### Requirements Wildcard certificates require DNS-01 challenge (not HTTP). You need: - DNS provider API access - Certbot DNS plugin for your provider ### Example with Cloudflare Install the plugin: ```bash sudo apt install python3-certbot-dns-cloudflare -y ``` Create credentials file: ```bash sudo mkdir -p /etc/letsencrypt sudo nano /etc/letsencrypt/cloudflare.ini ``` ```ini dns_cloudflare_api_token = your-api-token ``` Secure the file: ```bash sudo chmod 600 /etc/letsencrypt/cloudflare.ini ``` Get the wildcard certificate: ```bash sudo certbot certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ -d yourdomain.com \ -d "*.yourdomain.com" ``` ### DNS plugins for other providers | Provider | Plugin | |----------|--------| | Cloudflare | `python3-certbot-dns-cloudflare` | | DigitalOcean | `python3-certbot-dns-digitalocean` | | Route53 | `python3-certbot-dns-route53` | | Google Cloud | `python3-certbot-dns-google` | ## Certificate locations Certbot stores certificates in: ``` /etc/letsencrypt/live/yourdomain.com/ ├── cert.pem # Your certificate ├── chain.pem # Intermediate certificates ├── fullchain.pem # cert.pem + chain.pem (use this) ├── privkey.pem # Private key ``` Always use `fullchain.pem` for the certificate and `privkey.pem` for the key. ## Manual renewal Force renewal of all certificates: ```bash sudo certbot renew --force-renewal ``` Renew a specific certificate: ```bash sudo certbot certonly --force-renewal -d yourdomain.com ``` ## Check certificate expiration ```bash sudo certbot certificates ``` Or check a specific domain: ```bash echo | openssl s_client -servername yourdomain.com -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates ``` ## Rate limits Let's Encrypt has rate limits: | Limit | Value | |-------|-------| | Certificates per domain | 50 per week | | Duplicate certificates | 5 per week | | Failed validations | 5 per hour | For testing, use the staging environment: ```bash sudo certbot --nginx --staging -d yourdomain.com ``` Remove `--staging` when ready for production. ## Troubleshooting ### Challenge failed - connection refused Port 80 must be open and accessible: ```bash sudo ufw allow 80 sudo ss -tlnp | grep :80 ``` Test from outside: ```bash curl -I http://yourdomain.com ``` ### DNS problem - NXDOMAIN Your DNS is not pointing to your VPS: ```bash dig +short yourdomain.com ``` Wait for DNS propagation (up to 48 hours, usually minutes). ### Too many certificates already issued You hit rate limits. Wait a week or use a different subdomain for testing. ### Certificate not trusted You are using the staging certificate. Re-run Certbot without `--staging`. ### Auto-renewal not working Check the timer: ```bash sudo systemctl status certbot.timer journalctl -u certbot ``` Test renewal: ```bash sudo certbot renew --dry-run ``` ### Permission denied reading certificates Ensure proper permissions: ```bash sudo chmod 755 /etc/letsencrypt/live sudo chmod 755 /etc/letsencrypt/archive ``` For non-root services, add them to the `ssl-cert` group: ```bash sudo usermod -aG ssl-cert www-data sudo chgrp -R ssl-cert /etc/letsencrypt/live sudo chgrp -R ssl-cert /etc/letsencrypt/archive ``` ## Nginx SSL best practices After getting your certificate, optimize your Nginx SSL config: ```bash sudo nano /etc/nginx/sites-available/yourdomain.com ``` ```nginx server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; # Modern SSL configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; # OCSP stapling ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 8.8.8.8 valid=300s; # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # ... rest of your config } ``` Test your SSL configuration at [SSL Labs](https://www.ssllabs.com/ssltest/). ## Where to go next **Tutorials:** - [Nginx Reverse Proxy Guide](/guide/nginx-reverse-proxy) - [VPS Security Hardening](/guide/vps-security) - [Deploy Next.js to VPS](/deploy/nextjs) **Comparisons:** - [VPS Providers Comparison](https://deployhandbook.com/compare/hetzner-vs-digitalocean) **ServerCompass:** - [Automatic SSL for all your apps](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## Traefik SSL on VPS: Complete Setup Guide Source: https://deploytovps.com/blog/guide/traefik-ssl Published: 2026-03-06 Tags: traefik, ssl, letsencrypt, reverse-proxy, vps Set up Traefik as a reverse proxy with automatic Let's Encrypt SSL certificates. The foundation for hosting multiple apps on one VPS. Traefik is a modern reverse proxy that automatically discovers your Docker containers and handles [SSL certificates](https://servercompass.app/docs/ssl-certificates). Once set up, adding new apps with HTTPS is trivial. ## What you will have at the end - Traefik running as your edge router - Automatic Let's Encrypt certificates for all domains - HTTP to HTTPS redirect - A foundation for hosting unlimited apps on one VPS ## Step 1: Create the Traefik directory structure ```bash mkdir -p ~/traefik cd ~/traefik touch acme.json chmod 600 acme.json ``` The `acme.json` file stores your SSL certificates. The 600 permission is required by Traefik. ## Step 2: Create the Docker network Traefik needs a shared network to communicate with your app containers. ```bash docker network create traefik-public ``` ## Step 3: Create the Traefik configuration Create `traefik.yml`: ```yaml api: dashboard: true insecure: false entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" providers: docker: endpoint: "unix:///var/run/docker.sock" exposedByDefault: false network: traefik-public certificatesResolvers: letsencrypt: acme: email: you@example.com storage: /letsencrypt/acme.json tlsChallenge: true ``` Replace `you@example.com` with your email for Let's Encrypt notifications. ## Step 4: Create docker-compose.yml ```yaml services: traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped security_opt: - no-new-privileges:true ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yml:/traefik.yml:ro - ./acme.json:/letsencrypt/acme.json networks: - traefik-public labels: - "traefik.enable=true" # Dashboard (optional - remove in production or secure properly) - "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)" - "traefik.http.routers.dashboard.service=api@internal" - "traefik.http.routers.dashboard.entrypoints=websecure" - "traefik.http.routers.dashboard.tls=true" - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" # Basic auth for dashboard (generate with: htpasswd -nb admin password) - "traefik.http.routers.dashboard.middlewares=auth" - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz..." networks: traefik-public: external: true ``` ## Step 5: Start Traefik ```bash docker compose up -d ``` Check the logs to ensure certificates are being issued: ```bash docker logs -f traefik ``` You should see messages about ACME certificate generation. ## Step 6: Deploy an app with automatic SSL Now any app can get automatic HTTPS by joining the `traefik-public` network and adding labels. Example `docker-compose.yml` for a web app: ```yaml services: myapp: image: nginx:alpine restart: unless-stopped networks: - traefik-public labels: - "traefik.enable=true" - "traefik.http.routers.myapp.rule=Host(`app.yourdomain.com`)" - "traefik.http.routers.myapp.entrypoints=websecure" - "traefik.http.routers.myapp.tls=true" - "traefik.http.routers.myapp.tls.certresolver=letsencrypt" - "traefik.http.services.myapp.loadbalancer.server.port=80" networks: traefik-public: external: true ``` Deploy with: ```bash docker compose up -d ``` Traefik will automatically: 1. Detect the new container 2. Request an [SSL certificate](https://servercompass.app/docs/ssl-issues) from Let's Encrypt 3. Route traffic to your app ## Step 7: Verify SSL ```bash curl -I https://app.yourdomain.com ``` You should see a 200 response with valid SSL. Check certificate details: ```bash echo | openssl s_client -servername app.yourdomain.com -connect app.yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates ``` ## Common Traefik labels explained ```yaml # Enable Traefik for this container - "traefik.enable=true" # Route requests for this domain to this container - "traefik.http.routers.myapp.rule=Host(`example.com`)" # Use the HTTPS entrypoint - "traefik.http.routers.myapp.entrypoints=websecure" # Enable TLS - "traefik.http.routers.myapp.tls=true" # Use Let's Encrypt for certificates - "traefik.http.routers.myapp.tls.certresolver=letsencrypt" # Tell Traefik which port your app listens on - "traefik.http.services.myapp.loadbalancer.server.port=3000" ``` ## Multiple domains on one app ```yaml labels: - "traefik.http.routers.myapp.rule=Host(`example.com`) || Host(`www.example.com`)" ``` ## Path-based routing ```yaml labels: - "traefik.http.routers.api.rule=Host(`example.com`) && PathPrefix(`/api`)" ``` ## Troubleshooting ### Certificates not being issued 1. **Check DNS:** Ensure your domain points to your VPS IP ```bash dig +short yourdomain.com ``` 2. **Check ports:** Ensure 80 and 443 are open ```bash sudo ufw status ``` 3. **Check Traefik logs:** ```bash docker logs traefik 2>&1 | grep -i acme ``` 4. **Check acme.json permissions:** ```bash ls -la acme.json # Should be -rw------- (600) ``` ### 502 Bad Gateway - Your app container isn't running or isn't on the `traefik-public` network - The port in the label doesn't match your app's actual port - Check: `docker network inspect traefik-public` ### Certificate renewal Let's Encrypt certificates expire after 90 days. Traefik automatically renews them when they have less than 30 days remaining. No action needed. ### Rate limits Let's Encrypt has rate limits: - 50 certificates per domain per week - 5 duplicate certificates per week For testing, use the staging environment: ```yaml certificatesResolvers: letsencrypt: acme: caServer: https://acme-staging-v02.api.letsencrypt.org/directory # ... rest of config ``` ## Where to go next **Tutorials:** - [Deploy Next.js to VPS](/deploy/nextjs) - [Multiple Domains on VPS](/guide/multiple-domains-vps) **Comparisons:** - [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) **ServerCompass:** - [Automatic SSL for all your apps](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## VPS Security Hardening: Complete Guide Source: https://deploytovps.com/blog/guide/vps-security Published: 2026-03-06 Tags: security, vps, ssh, firewall, fail2ban, hardening Secure your VPS with SSH hardening, firewall configuration, automatic updates, fail2ban, and container security best practices. A practical guide to securing your VPS. These steps protect against the most common attacks: brute force, unauthorized access, and unpatched vulnerabilities. ## Overview This guide covers: 1. [SSH hardening](https://servercompass.app/features/ssh-hardening) (most important) 2. Firewall configuration 3. Automatic security updates 4. Fail2ban for brute force protection 5. Docker security 6. Monitoring and auditing Do these in order. SSH hardening alone blocks most attacks. ## Step 1: Create a non-root user Never use root for daily operations. ```bash adduser deploy usermod -aG sudo deploy ``` Test sudo access: ```bash su - deploy sudo whoami # Should output: root ``` ## Step 2: SSH key authentication SSH keys are more secure than passwords. Generate a key on your local machine: ```bash ssh-keygen -t ed25519 -C "your-email@example.com" ``` Copy it to your VPS: ```bash ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@YOUR_VPS_IP ``` Test key-based login: ```bash ssh deploy@YOUR_VPS_IP ``` ## Step 3: Harden SSH configuration Edit the SSH config: ```bash sudo nano /etc/ssh/sshd_config ``` Make these changes: ```bash # Disable root login PermitRootLogin no # Disable password authentication PasswordAuthentication no # Disable empty passwords PermitEmptyPasswords no # Use only SSH protocol 2 Protocol 2 # Limit authentication attempts MaxAuthTries 3 # Set login grace time LoginGraceTime 30 # Disable X11 forwarding (unless needed) X11Forwarding no # Disable TCP forwarding (unless needed) AllowTcpForwarding no # Only allow specific users AllowUsers deploy # Use strong ciphers Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org ``` Test the configuration: ```bash sudo sshd -t ``` Restart SSH: ```bash sudo systemctl restart sshd ``` **Important:** Keep your current SSH session open and test a new connection before closing it. ## Step 4: Change SSH port (optional) Changing the default port reduces automated attacks. ```bash sudo nano /etc/ssh/sshd_config ``` ```bash Port 2222 # Choose any port between 1024-65535 ``` Update firewall before restarting SSH: ```bash sudo ufw allow 2222/tcp sudo systemctl restart sshd ``` Connect using the new port: ```bash ssh -p 2222 deploy@YOUR_VPS_IP ``` ## Step 5: Configure the firewall (UFW) UFW (Uncomplicated Firewall) is the easiest way to manage iptables. ### Enable UFW ```bash # Set default policies sudo ufw default deny incoming sudo ufw default allow outgoing # Allow SSH (use your port if changed) sudo ufw allow 22/tcp # or sudo ufw allow 2222/tcp # Allow HTTP and HTTPS sudo ufw allow 80/tcp sudo ufw allow 443/tcp # Enable the firewall sudo ufw enable # Check status sudo ufw status verbose ``` ### Rate limiting Limit connection attempts to SSH: ```bash sudo ufw limit 22/tcp ``` This allows 6 connections per 30 seconds, then blocks. ### Allow specific services ```bash # PostgreSQL from specific IP only sudo ufw allow from 10.0.0.5 to any port 5432 # Redis (local only) sudo ufw allow from 127.0.0.1 to any port 6379 ``` ## Step 6: Install Fail2ban Fail2ban automatically blocks IPs that show malicious behavior. ### Install ```bash sudo apt update sudo apt install fail2ban -y ``` ### Configure Create a local config (do not edit the main config): ```bash sudo nano /etc/fail2ban/jail.local ``` ```ini [DEFAULT] # Ban for 1 hour bantime = 3600 # Check the last 10 minutes findtime = 600 # Ban after 5 failures maxretry = 5 # Email notifications (optional) # destemail = you@example.com # action = %(action_mwl)s [sshd] enabled = true port = ssh # or your custom port filter = sshd logpath = /var/log/auth.log maxretry = 3 bantime = 86400 # 24 hours for SSH [nginx-http-auth] enabled = true filter = nginx-http-auth port = http,https logpath = /var/log/nginx/error.log [nginx-limit-req] enabled = true filter = nginx-limit-req port = http,https logpath = /var/log/nginx/error.log ``` ### Start Fail2ban ```bash sudo systemctl enable fail2ban sudo systemctl start fail2ban ``` ### Check status ```bash sudo fail2ban-client status sudo fail2ban-client status sshd ``` ### Unban an IP ```bash sudo fail2ban-client set sshd unbanip 1.2.3.4 ``` ## Step 7: Automatic security updates Enable unattended upgrades for security patches. ### Install ```bash sudo apt install unattended-upgrades apt-listchanges -y ``` ### Configure ```bash sudo dpkg-reconfigure -plow unattended-upgrades ``` Select "Yes" to enable automatic updates. ### Customize (optional) ```bash sudo nano /etc/apt/apt.conf.d/50unattended-upgrades ``` Key settings: ``` // Automatically reboot if required Unattended-Upgrade::Automatic-Reboot "true"; Unattended-Upgrade::Automatic-Reboot-Time "04:00"; // Email notifications Unattended-Upgrade::Mail "you@example.com"; // Remove unused dependencies Unattended-Upgrade::Remove-Unused-Dependencies "true"; ``` ### Verify ```bash sudo unattended-upgrades --dry-run --debug ``` ## Step 8: Secure shared memory Prevent shared memory attacks: ```bash sudo nano /etc/fstab ``` Add this line: ``` tmpfs /run/shm tmpfs defaults,noexec,nosuid 0 0 ``` Apply: ```bash sudo mount -o remount /run/shm ``` ## Step 9: Docker security If running Docker, apply these security measures. ### Do not run containers as root ```dockerfile FROM node:20-alpine RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser # ... rest of Dockerfile ``` ### Use read-only filesystems ```yaml services: app: image: your-app read_only: true tmpfs: - /tmp - /var/run ``` ### Limit container capabilities ```yaml services: app: image: your-app cap_drop: - ALL cap_add: - NET_BIND_SERVICE # Only if needed security_opt: - no-new-privileges:true ``` ### Set resource limits ```yaml services: app: image: your-app deploy: resources: limits: cpus: '1' memory: 512M reservations: cpus: '0.25' memory: 128M ``` ### Use Docker secrets for sensitive data ```yaml services: app: image: your-app secrets: - db_password secrets: db_password: file: ./secrets/db_password.txt ``` ### Keep Docker updated ```bash sudo apt update sudo apt upgrade docker-ce docker-ce-cli containerd.io -y ``` ### Scan images for vulnerabilities ```bash docker scout cves your-image:latest ``` Or use Trivy: ```bash sudo apt install trivy -y trivy image your-image:latest ``` ## Step 10: Set up log monitoring ### Install Logwatch ```bash sudo apt install logwatch -y ``` Configure daily email reports: ```bash sudo nano /etc/cron.daily/00logwatch ``` ```bash #!/bin/bash /usr/sbin/logwatch --output mail --mailto you@example.com --detail high ``` ### Monitor auth logs Watch for login attempts in real-time: ```bash sudo tail -f /var/log/auth.log ``` ### Check for rootkits Install rkhunter: ```bash sudo apt install rkhunter -y sudo rkhunter --update sudo rkhunter --check ``` ## Step 11: Disable unused services List running services: ```bash sudo systemctl list-units --type=service --state=running ``` Disable unnecessary services: ```bash sudo systemctl disable cups # Printing sudo systemctl disable avahi-daemon # mDNS sudo systemctl disable bluetooth # Bluetooth ``` ## Step 12: Set up intrusion detection (optional) For more security, install AIDE: ```bash sudo apt install aide -y sudo aideinit sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db ``` Run daily checks: ```bash sudo nano /etc/cron.daily/aide-check ``` ```bash #!/bin/bash /usr/bin/aide --check | mail -s "AIDE report" you@example.com ``` ```bash sudo chmod +x /etc/cron.daily/aide-check ``` ## Security checklist Use this checklist to verify your setup: - [ ] Non-root user created with sudo access - [ ] SSH key authentication enabled - [ ] SSH password authentication disabled - [ ] Root SSH login disabled - [ ] Firewall enabled with minimal open ports - [ ] Fail2ban installed and configured - [ ] Automatic security updates enabled - [ ] Docker containers running as non-root - [ ] Unnecessary services disabled - [ ] Log monitoring configured ## Troubleshooting ### Locked out of SSH If you are locked out: 1. Access via VPS provider console (web-based) 2. Fix SSH config or authorized_keys 3. Restart SSH ### Fail2ban blocking legitimate IPs Check bans: ```bash sudo fail2ban-client status sshd ``` Unban: ```bash sudo fail2ban-client set sshd unbanip YOUR_IP ``` Add to whitelist: ```bash sudo nano /etc/fail2ban/jail.local ``` ```ini [DEFAULT] ignoreip = 127.0.0.1/8 YOUR_IP ``` ### UFW blocking needed traffic Check current rules: ```bash sudo ufw status numbered ``` Add missing rule: ```bash sudo ufw allow PORT/tcp ``` Delete a rule: ```bash sudo ufw delete NUMBER ``` ### Automatic updates causing issues Check logs: ```bash cat /var/log/unattended-upgrades/unattended-upgrades.log ``` Disable temporarily: ```bash sudo systemctl stop unattended-upgrades ``` ## Where to go next **Tutorials:** - [VPS Backup Strategy](/guide/backup-strategy) - [SSL Certificates on VPS](/guide/ssl-certificates) - [GitHub Actions Deploy to VPS](/guide/github-actions-vps) **Comparisons:** - [VPS Providers Security Comparison](https://deployhandbook.com/compare/hetzner-vs-digitalocean) **ServerCompass:** - [Automated security hardening](https://servercompass.app?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## How to Self-Host Chatwoot on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/chatwoot Published: 2026-03-06 Tags: chatwoot, customer-support, self-host, docker-compose Run Chatwoot customer support platform on your own VPS. Live chat, email, and social channels without per-agent pricing. Chatwoot is an open-source customer engagement platform. Live chat, email, Facebook, WhatsApp, and more in one inbox. Self-hosting means no per-agent fees. Scale your support team without scaling your bill. ## What you will have at the end - Chatwoot running at `https://chat.your-domain.com` - Automatic SSL via Traefik - PostgreSQL and Redis for production reliability - Ready to connect live chat widget to your site ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` Verify [Docker Compose](https://servercompass.app/features/docker-compose-editor) version (need v2.14+): ```bash docker compose version ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/chatwoot cd ~/apps/chatwoot ``` ## Step 3: Download Chatwoot configuration ```bash # Get the official docker-compose file wget -O docker-compose.yaml https://raw.githubusercontent.com/chatwoot/chatwoot/develop/docker-compose.production.yaml # Get the environment template wget -O .env https://raw.githubusercontent.com/chatwoot/chatwoot/develop/.env.example ``` ## Step 4: Create docker-compose.yml with Traefik Create a custom `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped postgres: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_USER: chatwoot POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: chatwoot volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U chatwoot"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redis_data:/data chatwoot: image: chatwoot/chatwoot:latest restart: unless-stopped depends_on: postgres: condition: service_healthy redis: condition: service_started environment: - SECRET_KEY_BASE=${SECRET_KEY_BASE} - FRONTEND_URL=https://chat.your-domain.com - POSTGRES_HOST=postgres - POSTGRES_USERNAME=chatwoot - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DATABASE=chatwoot - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - RAILS_ENV=production - ENABLE_ACCOUNT_SIGNUP=false labels: - traefik.enable=true - traefik.http.routers.chatwoot.rule=Host(`chat.your-domain.com`) - traefik.http.routers.chatwoot.entrypoints=websecure - traefik.http.routers.chatwoot.tls=true - traefik.http.routers.chatwoot.tls.certresolver=letsencrypt - traefik.http.services.chatwoot.loadbalancer.server.port=3000 sidekiq: image: chatwoot/chatwoot:latest restart: unless-stopped depends_on: - chatwoot command: bundle exec sidekiq -C config/sidekiq.yml environment: - SECRET_KEY_BASE=${SECRET_KEY_BASE} - POSTGRES_HOST=postgres - POSTGRES_USERNAME=chatwoot - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DATABASE=chatwoot - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - RAILS_ENV=production volumes: letsencrypt: postgres_data: redis_data: ``` ## Step 5: Configure environment variables Create `.env` file: ```bash # Generate secrets SECRET_KEY_BASE=$(openssl rand -hex 64) POSTGRES_PASSWORD=$(openssl rand -hex 16) REDIS_PASSWORD=$(openssl rand -hex 16) cat > .env << EOF SECRET_KEY_BASE=$SECRET_KEY_BASE POSTGRES_PASSWORD=$POSTGRES_PASSWORD REDIS_PASSWORD=$REDIS_PASSWORD EOF ``` ## Step 6: Initialize the database ```bash # Start databases first docker compose up -d postgres redis # Wait for postgres to be ready sleep 10 # Run database setup docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare ``` ## Step 7: Deploy ```bash docker compose up -d ``` ## Step 8: Create admin account Visit `https://chat.your-domain.com` and create your first account. If `ENABLE_ACCOUNT_SIGNUP=false`, create via console: ```bash docker compose exec chatwoot bundle exec rails console ``` Then in the console: ```ruby User.create!(name: 'Admin', email: 'admin@example.com', password: 'securepassword', confirmed_at: Time.now) ``` ## Step 9: Add live chat to your website 1. Go to Settings > Inboxes > Add Inbox 2. Select "Website" 3. Configure your widget settings 4. Copy the JavaScript snippet to your site ## Backup strategy Chatwoot needs PostgreSQL and Redis backups. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/chatwoot/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # Postgres dump docker exec chatwoot-postgres-1 pg_dump -U chatwoot chatwoot > $BACKUP_DIR/chatwoot.sql # Redis dump (for session data) docker exec chatwoot-redis-1 redis-cli -a $REDIS_PASSWORD BGSAVE sleep 5 docker cp chatwoot-redis-1:/data/dump.rdb $BACKUP_DIR/redis.rdb # Compress tar -czf $BACKUP_DIR.tar.gz $BACKUP_DIR ``` ## Troubleshooting ### Chatwoot shows 500 error - Check if database migrations ran: `docker compose logs chatwoot` - Run migrations manually: `docker compose run --rm chatwoot bundle exec rails db:migrate` - Verify [environment variables](https://servercompass.app/features/env-vars-editor) are set correctly ### Live chat widget not loading - Verify FRONTEND_URL matches your domain - Check browser console for CORS errors - Ensure the widget script is loaded correctly ### Emails not sending - Configure SMTP settings in .env - Required: SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD - Test with: `docker compose exec chatwoot bundle exec rails console` then `ActionMailer::Base.mail(to: 'test@example.com', subject: 'Test', body: 'Test').deliver_now` ### Sidekiq jobs stuck - Check Sidekiq logs: `docker compose logs sidekiq` - Verify Redis connection - Monitor queue: visit `/sidekiq` (admin only) ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host n8n](/self-host/n8n) - Comparison: [Intercom Alternatives](https://deployhandbook.com/alternatives/intercom) - ServerCompass: [One-click Chatwoot deployment](https://servercompass.app/templates/chatwoot?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, [1devtool](https://1devtool.com) is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in. --- ## How to Self-Host Ghost CMS on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/ghost Published: 2026-03-06 Tags: ghost, cms, self-host, docker-compose Run Ghost CMS on your own VPS. Professional publishing platform without subscription fees. Ghost is a professional publishing platform. Clean editor, memberships, newsletters, and SEO built in. Self-hosting Ghost means no per-member fees for paid subscriptions. Keep 100% of your membership revenue. ## What you will have at the end - Ghost running at `https://blog.your-domain.com` - Automatic SSL via Traefik - MySQL database for production reliability - Content and images persisted ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/ghost/content cd ~/apps/ghost ``` ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped mysql: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ghost MYSQL_USER: ghost MYSQL_PASSWORD: ${MYSQL_PASSWORD} volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 ghost: image: ghost:5-alpine restart: unless-stopped depends_on: mysql: condition: service_healthy environment: url: https://blog.your-domain.com database__client: mysql database__connection__host: mysql database__connection__user: ghost database__connection__password: ${MYSQL_PASSWORD} database__connection__database: ghost mail__transport: SMTP mail__options__host: ${SMTP_HOST} mail__options__port: ${SMTP_PORT} mail__options__auth__user: ${SMTP_USER} mail__options__auth__pass: ${SMTP_PASSWORD} volumes: - ./content:/var/lib/ghost/content labels: - traefik.enable=true - traefik.http.routers.ghost.rule=Host(`blog.your-domain.com`) - traefik.http.routers.ghost.entrypoints=websecure - traefik.http.routers.ghost.tls=true - traefik.http.routers.ghost.tls.certresolver=letsencrypt - traefik.http.services.ghost.loadbalancer.server.port=2368 volumes: letsencrypt: mysql_data: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # Database passwords MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16) MYSQL_PASSWORD=$(openssl rand -hex 16) # SMTP settings (for newsletters and member emails) SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=your-smtp-user SMTP_PASSWORD=your-smtp-password cat > .env << EOF MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD MYSQL_PASSWORD=$MYSQL_PASSWORD SMTP_HOST=$SMTP_HOST SMTP_PORT=$SMTP_PORT SMTP_USER=$SMTP_USER SMTP_PASSWORD=$SMTP_PASSWORD EOF ``` Configure SMTP for newsletters. Without it, member features are limited. ## Step 5: Deploy ```bash docker compose up -d ``` Wait for MySQL to initialize before Ghost starts. ## Step 6: Complete setup Visit `https://blog.your-domain.com/ghost` to access the admin panel. Create your admin account and configure: 1. Site title and description 2. Staff users 3. Navigation 4. Theme (default Casper or upload custom) ## Step 7: Configure memberships (optional) For paid subscriptions: 1. Go to Settings > Membership 2. Connect Stripe for payments 3. Configure subscription tiers 4. Customize signup portal Ghost handles member management, payments, and email delivery. ## Step 8: Configure newsletters 1. Go to Settings > Email newsletter 2. Configure sender details 3. Connect to Mailgun or use direct SMTP 4. Test email delivery ## Backup strategy Ghost needs MySQL and content directory backups. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/ghost/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # MySQL dump docker exec ghost-mysql-1 mysqldump -u ghost -p$MYSQL_PASSWORD ghost > $BACKUP_DIR/ghost.sql # Content directory (images, themes, settings) tar -czf $BACKUP_DIR/content.tar.gz ~/apps/ghost/content # Compress everything tar -czf $BACKUP_DIR.tar.gz $BACKUP_DIR ``` Store backups off-server. Consider automated S3 uploads. ## Troubleshooting ### Cannot access admin panel - Admin is at `/ghost`, not `/admin` - Clear browser cache - Check Ghost logs: `docker compose logs ghost` ### Images not uploading - Check content directory permissions: `ls -la ~/apps/ghost/content` - Verify disk space: `df -h` - Ghost container needs write access to content volume ### Emails not sending - Verify SMTP credentials - Check mail logs in Ghost admin - Test with simpler SMTP settings first - Consider using Mailgun for better deliverability ### Site shows wrong URL - The `url` environment variable must match your domain exactly - Restart Ghost after changing: `docker compose restart ghost` - Clear Ghost cache if needed ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host Plausible Analytics](/self-host/plausible) - Comparison: [WordPress Alternatives](https://deployhandbook.com/alternatives/wordpress) - ServerCompass: [One-click Ghost deployment](https://servercompass.app/templates/ghost?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Self-Host MinIO on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/minio Published: 2026-03-06 Tags: minio, object-storage, self-host, docker-compose Run MinIO S3-compatible object storage on your own VPS. Store files without cloud storage fees. MinIO is S3-compatible object storage you can run anywhere. Same API as AWS S3, but on your own infrastructure. Self-hosting MinIO means predictable storage costs. No egress fees, no per-request pricing. ## What you will have at the end - MinIO running at `https://minio.your-domain.com` - MinIO Console at `https://console.minio.your-domain.com` - Automatic SSL via Traefik - S3-compatible API for your applications ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/minio/data cd ~/apps/minio ``` Ensure the data directory has enough space for your storage needs. ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped minio: image: quay.io/minio/minio:latest restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_BROWSER_REDIRECT_URL: https://console.minio.your-domain.com volumes: - ./data:/data healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 30s timeout: 20s retries: 3 labels: # API endpoint - traefik.enable=true - traefik.http.routers.minio-api.rule=Host(`minio.your-domain.com`) - traefik.http.routers.minio-api.entrypoints=websecure - traefik.http.routers.minio-api.tls=true - traefik.http.routers.minio-api.tls.certresolver=letsencrypt - traefik.http.routers.minio-api.service=minio-api - traefik.http.services.minio-api.loadbalancer.server.port=9000 # Console - traefik.http.routers.minio-console.rule=Host(`console.minio.your-domain.com`) - traefik.http.routers.minio-console.entrypoints=websecure - traefik.http.routers.minio-console.tls=true - traefik.http.routers.minio-console.tls.certresolver=letsencrypt - traefik.http.routers.minio-console.service=minio-console - traefik.http.services.minio-console.loadbalancer.server.port=9001 volumes: letsencrypt: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # MinIO credentials (use strong values!) MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=$(openssl rand -base64 32) cat > .env << EOF MINIO_ROOT_USER=$MINIO_ROOT_USER MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD EOF echo "MinIO Root Password: $MINIO_ROOT_PASSWORD" ``` Save these credentials securely. They are your S3 access keys. ## Step 5: Deploy ```bash docker compose up -d ``` ## Step 6: Access the console Visit `https://console.minio.your-domain.com` and log in with your credentials. ## Step 7: Create your first bucket 1. Click "Create Bucket" 2. Enter a bucket name (lowercase, no spaces) 3. Configure versioning and locking (optional) 4. Set access policy (private by default) ## Step 8: Configure S3 client Use any S3-compatible client or SDK: ```bash # Install MinIO client wget https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc sudo mv mc /usr/local/bin/ # Configure alias mc alias set myminio https://minio.your-domain.com $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD # Test mc ls myminio ``` ## Step 9: Use with applications For applications expecting S3: ```bash # Environment variables AWS_ACCESS_KEY_ID=your-minio-root-user AWS_SECRET_ACCESS_KEY=your-minio-root-password AWS_ENDPOINT_URL=https://minio.your-domain.com AWS_REGION=us-east-1 # MinIO ignores this but some SDKs require it ``` ## Backup strategy MinIO data is in the mounted volume. Back it up like any other data. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/minio/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # Use mc mirror for efficient backup mc mirror myminio/ $BACKUP_DIR/data/ # Or tar the data directory (stop MinIO first for consistency) # docker compose stop minio # tar -czf $BACKUP_DIR/minio-data.tar.gz ~/apps/minio/data # docker compose start minio ``` For production, consider MinIO's built-in replication to another MinIO instance. ## Troubleshooting ### Cannot connect with S3 SDK - Verify endpoint URL includes https:// - Check credentials match exactly - Some SDKs need `force_path_style: true` for MinIO - Verify the bucket exists ### Upload fails with 403 - Check bucket policy allows writes - Verify access key permissions - Review MinIO logs: `docker compose logs minio` ### Console shows blank page - Clear browser cache - Verify MINIO_BROWSER_REDIRECT_URL matches your console domain - Check Traefik routing: `docker compose logs traefik` ### Slow upload/download speeds - SSD storage significantly improves performance - Check network bandwidth - Consider enabling MinIO caching for read-heavy workloads ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host Supabase](/self-host/supabase) - Comparison: [AWS S3 Alternatives](https://deployhandbook.com/alternatives/aws-s3) - ServerCompass: [One-click MinIO deployment](https://servercompass.app/templates/minio?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Self-Host n8n on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/n8n Published: 2026-03-06 Tags: n8n, automation, self-host, docker-compose Run n8n workflow automation on your own VPS. Build integrations without per-execution pricing. n8n is a workflow automation tool that connects your apps and services. Self-hosting means unlimited executions at flat server cost. The managed version charges per execution. If you run many workflows, self-hosting makes financial sense. ## What you will have at the end - n8n running at `https://n8n.your-domain.com` - Automatic SSL via Traefik - PostgreSQL database for production reliability - Persistent workflow storage ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/n8n/data mkdir -p ~/apps/n8n/files cd ~/apps/n8n ``` ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped postgres: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_USER: n8n POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: n8n volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n"] interval: 10s timeout: 5s retries: 5 n8n: image: n8nio/n8n:latest restart: unless-stopped depends_on: postgres: condition: service_healthy environment: - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=n8n - DB_POSTGRESDB_USER=n8n - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} - N8N_BASIC_AUTH_ACTIVE=true - N8N_BASIC_AUTH_USER=${N8N_USER} - N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD} - N8N_HOST=n8n.your-domain.com - N8N_PORT=5678 - N8N_PROTOCOL=https - WEBHOOK_URL=https://n8n.your-domain.com/ - GENERIC_TIMEZONE=UTC volumes: - ./data:/home/node/.n8n - ./files:/files labels: - traefik.enable=true - traefik.http.routers.n8n.rule=Host(`n8n.your-domain.com`) - traefik.http.routers.n8n.entrypoints=websecure - traefik.http.routers.n8n.tls=true - traefik.http.routers.n8n.tls.certresolver=letsencrypt - traefik.http.services.n8n.loadbalancer.server.port=5678 volumes: letsencrypt: postgres_data: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # Database password POSTGRES_PASSWORD=$(openssl rand -hex 16) # n8n admin credentials N8N_USER=admin N8N_PASSWORD=$(openssl rand -base64 24) # Save to .env cat > .env << EOF POSTGRES_PASSWORD=$POSTGRES_PASSWORD N8N_USER=$N8N_USER N8N_PASSWORD=$N8N_PASSWORD EOF echo "Your n8n credentials:" echo "User: $N8N_USER" echo "Password: $N8N_PASSWORD" ``` Save these credentials securely. ## Step 5: Deploy ```bash docker compose up -d ``` Wait for PostgreSQL to initialize before n8n starts. ## Step 6: Verify installation ```bash docker compose ps docker compose logs -f n8n ``` Visit `https://n8n.your-domain.com` and log in with your credentials. ## Step 7: Configure webhooks For webhook triggers to work: 1. Your domain must be accessible from the internet 2. The WEBHOOK_URL must match your domain 3. Firewall must allow incoming connections on 443 Test with a simple webhook workflow to verify. ## Backup strategy n8n with PostgreSQL needs database backups and workflow exports. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/n8n/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # Postgres dump docker exec n8n-postgres-1 pg_dump -U n8n n8n > $BACKUP_DIR/n8n.sql # Backup local files tar -czf $BACKUP_DIR/n8n-data.tar.gz ~/apps/n8n/data # Compress tar -czf $BACKUP_DIR.tar.gz $BACKUP_DIR ``` Also consider exporting workflows as JSON from the n8n UI for version control. ## Troubleshooting ### Webhooks not triggering - Verify WEBHOOK_URL matches your domain exactly - Check firewall allows incoming HTTPS - Test with curl: `curl -X POST https://n8n.your-domain.com/webhook-test/test` - Review n8n logs for incoming requests ### Cannot connect to external services - Some services block datacenter IPs - Check if the service requires IP whitelisting - Verify DNS resolution inside container: `docker exec n8n-n8n-1 nslookup api.example.com` ### Workflows fail silently - Enable detailed logging: set `N8N_LOG_LEVEL=debug` - Check execution history in the n8n UI - Verify credentials are still valid ### High memory usage - PostgreSQL needs tuning for large workflow history - Consider pruning old executions - Increase VPS RAM if running many concurrent workflows ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host Uptime Kuma](/self-host/uptime-kuma) - Comparison: [Zapier Alternatives](https://deployhandbook.com/alternatives/zapier) - ServerCompass: [One-click n8n deployment](https://servercompass.app/templates/n8n?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Self-Host Plausible Analytics on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/plausible Published: 2026-03-06 Tags: plausible, analytics, self-host, docker-compose Run Plausible Analytics on your own VPS. Privacy-friendly, lightweight analytics without monthly fees. Plausible is a privacy-friendly alternative to Google Analytics. Lightweight script, no cookies, GDPR compliant by default. Self-hosting Plausible means you own your analytics data and avoid monthly SaaS fees. The Community Edition is fully featured. ## What you will have at the end - Plausible running at `https://analytics.your-domain.com` - Automatic SSL via Traefik - ClickHouse for fast analytics queries - A tracking script you control ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Clone the Community Edition repository ```bash mkdir -p ~/apps cd ~/apps git clone https://github.com/plausible/community-edition.git plausible cd plausible ``` ## Step 3: Create docker-compose.yml with Traefik Create or modify `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped plausible_db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: plausible POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: plausible volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U plausible"] interval: 10s timeout: 5s retries: 5 plausible_events_db: image: clickhouse/clickhouse-server:24.3-alpine restart: unless-stopped volumes: - clickhouse_data:/var/lib/clickhouse - clickhouse_logs:/var/log/clickhouse-server ulimits: nofile: soft: 262144 hard: 262144 plausible: image: ghcr.io/plausible/community-edition:v2.1 restart: unless-stopped depends_on: plausible_db: condition: service_healthy plausible_events_db: condition: service_started command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" environment: - BASE_URL=https://analytics.your-domain.com - SECRET_KEY_BASE=${SECRET_KEY_BASE} - TOTP_VAULT_KEY=${TOTP_VAULT_KEY} - DATABASE_URL=postgres://plausible:${POSTGRES_PASSWORD}@plausible_db:5432/plausible - CLICKHOUSE_DATABASE_URL=http://plausible_events_db:8123/plausible_events_db - DISABLE_REGISTRATION=invite_only labels: - traefik.enable=true - traefik.http.routers.plausible.rule=Host(`analytics.your-domain.com`) - traefik.http.routers.plausible.entrypoints=websecure - traefik.http.routers.plausible.tls=true - traefik.http.routers.plausible.tls.certresolver=letsencrypt - traefik.http.services.plausible.loadbalancer.server.port=8000 volumes: letsencrypt: postgres_data: clickhouse_data: clickhouse_logs: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # Generate secrets SECRET_KEY_BASE=$(openssl rand -base64 48 | tr -d '\n') TOTP_VAULT_KEY=$(openssl rand -base64 32 | tr -d '\n') POSTGRES_PASSWORD=$(openssl rand -hex 16) cat > .env << EOF SECRET_KEY_BASE=$SECRET_KEY_BASE TOTP_VAULT_KEY=$TOTP_VAULT_KEY POSTGRES_PASSWORD=$POSTGRES_PASSWORD EOF ``` Save these values. You need them for recovery. ## Step 5: Deploy ```bash docker compose up -d ``` First startup takes a minute as databases initialize. ## Step 6: Create admin account Visit `https://analytics.your-domain.com` and register your account. If DISABLE_REGISTRATION is set to `invite_only`, create via console: ```bash docker compose exec plausible /entrypoint.sh create-admin ``` ## Step 7: Add your first site 1. Log into the dashboard 2. Click "Add Website" 3. Enter your domain 4. Copy the tracking script ## Step 8: Add tracking to your site Add this script to your website's ``: ```html ``` For SPAs, use `script.hash.js`. For outbound link tracking, use `script.outbound-links.js`. ## Backup strategy Plausible needs PostgreSQL and ClickHouse backups. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/plausible/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # Postgres dump (config, users, sites) docker exec plausible-plausible_db-1 pg_dump -U plausible plausible > $BACKUP_DIR/postgres.sql # ClickHouse backup (events data - can be large) docker exec plausible-plausible_events_db-1 clickhouse-client --query "BACKUP DATABASE plausible_events_db TO Disk('backups', 'daily')" # Compress tar -czf $BACKUP_DIR.tar.gz $BACKUP_DIR ``` Note: ClickHouse data can be large. Consider incremental backups for high-traffic sites. ## Troubleshooting ### Script not tracking visits - Check browser console for errors - Verify the data-domain matches exactly - Ensure the script URL is accessible - Check for ad blockers (use proxy setup to avoid) ### ClickHouse fails to start - Verify CPU supports SSE 4.2: `grep -q sse4_2 /proc/cpuinfo && echo "OK"` - Check available memory: ClickHouse needs at least 2GB - Review logs: `docker compose logs plausible_events_db` ### Dashboard shows no data - Wait a few minutes for data to propagate - Verify the site domain matches your script - Check Plausible logs: `docker compose logs plausible` ### High disk usage from ClickHouse - ClickHouse stores analytics data long-term - Configure data retention in settings - Consider larger disk or data pruning ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host PostHog](/self-host/posthog) - Comparison: [Google Analytics Alternatives](https://deployhandbook.com/alternatives/google-analytics) - ServerCompass: [One-click Plausible deployment](https://servercompass.app/templates/plausible?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Self-Host PocketBase on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/pocketbase Published: 2026-03-06 Tags: pocketbase, backend, self-host, docker-compose Run PocketBase on your own VPS. A single binary backend with SQLite, auth, and realtime out of the box. PocketBase is a backend in a single file. SQLite database, auth, file storage, realtime subscriptions. No external dependencies. Self-hosting PocketBase is as simple as it gets. The binary runs anywhere, and Docker makes it even cleaner. ## What you will have at the end - PocketBase running at `https://pocketbase.your-domain.com` - Automatic SSL via Traefik - Persistent SQLite database - Admin dashboard ready to configure ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/pocketbase/pb_data mkdir -p ~/apps/pocketbase/pb_public cd ~/apps/pocketbase ``` ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped pocketbase: image: ghcr.io/muchobien/pocketbase:latest restart: unless-stopped command: - --encryptionEnv - PB_ENCRYPTION_KEY environment: PB_ENCRYPTION_KEY: ${PB_ENCRYPTION_KEY} volumes: - ./pb_data:/pb/pb_data - ./pb_public:/pb/pb_public labels: - traefik.enable=true - traefik.http.routers.pocketbase.rule=Host(`pocketbase.your-domain.com`) - traefik.http.routers.pocketbase.entrypoints=websecure - traefik.http.routers.pocketbase.tls=true - traefik.http.routers.pocketbase.tls.certresolver=letsencrypt - traefik.http.services.pocketbase.loadbalancer.server.port=8080 volumes: letsencrypt: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # Generate an encryption key for sensitive data PB_ENCRYPTION_KEY=$(openssl rand -hex 16) echo "PB_ENCRYPTION_KEY=$PB_ENCRYPTION_KEY" > .env ``` Save this key securely. You need it to access encrypted data. ## Step 5: Deploy ```bash docker compose up -d ``` PocketBase starts in seconds. ## Step 6: Create admin account Visit `https://pocketbase.your-domain.com/_/` to access the admin dashboard. On first visit, you will create your superuser account. This is your admin login. ## Step 7: Configure settings In the admin dashboard: 1. Go to Settings > Application 2. Set your application name 3. Configure SMTP for email verification (optional) 4. Set allowed OAuth providers (optional) ## Backup strategy PocketBase uses SQLite. Backing up is simple: copy the database file. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/pocketbase mkdir -p $BACKUP_DIR # Stop writes briefly for consistency docker compose pause pocketbase # Copy the database cp ~/apps/pocketbase/pb_data/data.db $BACKUP_DIR/data-$(date +%Y%m%d).db # Resume docker compose unpause pocketbase # Also backup uploaded files tar -czf $BACKUP_DIR/pb_public-$(date +%Y%m%d).tar.gz ~/apps/pocketbase/pb_public ``` For zero-downtime backups, use SQLite's `.backup` command or enable WAL mode. ## Troubleshooting ### Admin dashboard shows blank page - Clear browser cache and cookies - Check if the container is running: `docker compose ps` - Verify Traefik routing: `docker compose logs traefik` ### Cannot upload files - Check disk space: `df -h` - Verify volume permissions: `ls -la pb_data` - Increase upload limit in PocketBase settings ### Realtime subscriptions not working - Ensure WebSocket connections are allowed through your firewall - Check if Traefik is forwarding WebSocket headers - Verify your client is connecting to the correct URL ### Database locked errors - SQLite can only handle one writer at a time - Enable WAL mode for better concurrency - For high-write workloads, consider using PocketBase's built-in S3 storage ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host Supabase](/self-host/supabase) - Comparison: [Firebase Alternatives](https://deployhandbook.com/alternatives/firebase) - ServerCompass: [One-click PocketBase deployment](https://servercompass.app/templates/pocketbase?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## How to Self-Host PostHog on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/posthog Published: 2026-03-06 Tags: posthog, analytics, self-host, docker-compose Run PostHog on your own VPS for product analytics without per-event pricing. Full control over your data. PostHog is powerful until the event-based pricing catches up with your growth. Self-hosting PostHog means you own your analytics data and pay flat server costs. The trade-off: PostHog is a complex stack (ClickHouse, Kafka, Postgres, Redis). This guide walks through a production-ready setup. ## What you will have at the end - PostHog running at `https://posthog.your-domain.com` - Automatic SSL via Traefik - Persistent data volumes for ClickHouse and Postgres - A repeatable deployment you can back up and update ## Step 1: Prepare the server PostHog needs serious resources. Do not try this on a 2GB VPS. ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/posthog cd ~/apps/posthog ``` ## Step 3: Clone the PostHog Docker repository PostHog maintains official [Docker Compose](https://servercompass.app/features/docker-compose-editor) files. Use them. ```bash git clone https://github.com/PostHog/posthog.git cd posthog ``` Alternatively, create a minimal `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped db: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_USER: posthog POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: posthog volumes: - postgres_data:/var/lib/postgresql/data redis: image: redis:7-alpine restart: unless-stopped volumes: - redis_data:/data clickhouse: image: clickhouse/clickhouse-server:23.8-alpine restart: unless-stopped volumes: - clickhouse_data:/var/lib/clickhouse ulimits: nofile: soft: 262144 hard: 262144 posthog: image: posthog/posthog:latest restart: unless-stopped depends_on: - db - redis - clickhouse environment: DATABASE_URL: postgres://posthog:${POSTGRES_PASSWORD}@db:5432/posthog REDIS_URL: redis://redis:6379 CLICKHOUSE_HOST: clickhouse SECRET_KEY: ${SECRET_KEY} SITE_URL: https://posthog.your-domain.com IS_BEHIND_PROXY: "true" labels: - traefik.enable=true - traefik.http.routers.posthog.rule=Host(`posthog.your-domain.com`) - traefik.http.routers.posthog.entrypoints=websecure - traefik.http.routers.posthog.tls=true - traefik.http.routers.posthog.tls.certresolver=letsencrypt - traefik.http.services.posthog.loadbalancer.server.port=8000 volumes: letsencrypt: postgres_data: redis_data: clickhouse_data: ``` ## Step 4: Configure environment variables Create `.env` file: ```bash # Generate a secret key SECRET_KEY=$(openssl rand -hex 32) # Database password POSTGRES_PASSWORD=$(openssl rand -hex 16) # Site URL SITE_URL=https://posthog.your-domain.com ``` Save these values securely. You will need them for recovery. ## Step 5: Deploy ```bash docker compose up -d ``` First startup takes several minutes as ClickHouse initializes. ## Step 6: Verify installation ```bash docker compose ps docker compose logs -f posthog ``` Visit `https://posthog.your-domain.com` and create your admin account. ## Backup strategy PostHog stores data in multiple places. Back up all of them. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/posthog/$(date +%Y%m%d) mkdir -p $BACKUP_DIR # Postgres dump docker exec posthog-db-1 pg_dump -U posthog posthog > $BACKUP_DIR/postgres.sql # ClickHouse backup (large, consider incremental) docker exec posthog-clickhouse-1 clickhouse-client --query "BACKUP DATABASE default TO Disk('backups', 'daily')" # Compress tar -czf $BACKUP_DIR.tar.gz $BACKUP_DIR ``` Consider using your VPS provider's snapshot feature for simpler recovery. ## Troubleshooting ### ClickHouse out of memory ClickHouse is memory-hungry. If you see OOM errors: - Increase VPS RAM to at least 16GB - Add swap space as emergency buffer - Check `docker stats` for memory usage ### PostHog web UI returns 502 - Check if all containers are running: `docker compose ps` - Verify ClickHouse is healthy: `docker compose logs clickhouse` - Ensure Postgres migrations completed: `docker compose logs posthog` ### Events not appearing in dashboard - Verify the JS snippet points to your self-hosted URL - Check browser network tab for blocked requests - Review PostHog [container logs](https://servercompass.app/docs/viewing-container-logs) for ingestion errors ### Slow queries on large datasets - ClickHouse needs tuning for large installs - Consider adding more RAM or switching to dedicated ClickHouse - Enable query caching in PostHog settings ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host Plausible Analytics](/self-host/plausible) - Comparison: [Vercel Alternatives](https://deployhandbook.com/alternatives/vercel) - ServerCompass: [One-click PostHog deployment](https://servercompass.app/templates/posthog?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## How to Self-Host Supabase on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/supabase Published: 2026-03-06 Tags: supabase, postgres, self-host, docker-compose Run Supabase on your own VPS with HTTPS, persistent storage, and a deployment shape you can actually maintain. Supabase is fantastic until you are paying for it like a B2B platform. Self-hosting is not free: you take on ops responsibility. But if you have stable usage and you want predictable cost, a VPS Supabase can make sense. This guide shows a production-minded setup: HTTPS, persistent storage, and the things people forget (backups). ## What you will have at the end - Supabase running on your VPS - a domain with HTTPS - a folder layout you can back up - a repeatable way to update the stack ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create a clean directory layout ```bash mkdir -p ~/apps/supabase cd ~/apps/supabase mkdir -p volumes env ``` The goal is simple: your data and your config must be easy to find and back up. ## Step 3: Create environment variables Create `env/supabase.env`: ```bash SITE_URL=https://supabase.your-domain.com JWT_SECRET=change-me ANON_KEY=change-me SERVICE_ROLE_KEY=change-me ``` In practice you will generate proper secrets. Do not reuse example secrets. ## Step 4: Use Docker Compose (your deployment primitive) Supabase has multiple services. [Docker Compose](https://servercompass.app/features/docker-compose-editor) is the clean way to keep it manageable. Create `docker-compose.yml`: ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped supabase: image: supabase/supabase:latest env_file: - env/supabase.env labels: - traefik.enable=true - traefik.http.routers.supabase.rule=Host(`supabase.your-domain.com`) - traefik.http.routers.supabase.entrypoints=websecure - traefik.http.routers.supabase.tls=true - traefik.http.routers.supabase.tls.certresolver=letsencrypt - traefik.http.services.supabase.loadbalancer.server.port=3000 volumes: - ./volumes:/var/lib/supabase restart: unless-stopped volumes: letsencrypt: ``` This is a simplified representation. For a real production install, you will run the individual Supabase services (Postgres, Kong, Auth, Realtime, Storage) explicitly. The point of this tutorial is to make the deployment shape clear and maintainable. ## Step 5: Deploy and verify ```bash docker compose up -d docker compose ps ``` Check Traefik logs for TLS success: ```bash docker logs -f $(docker ps --filter name=traefik -q) ``` ## Step 6: Backups (do not skip this) If you self-host a database and you do not back it up, you are not self-hosting. You are gambling. At minimum: - nightly volume snapshot (provider feature) - nightly logical database dump (if you run Postgres directly) Example (when you have a `db` container): ```bash docker exec -t db pg_dumpall -c -U postgres > ~/backups/supabase.sql ``` ## Troubleshooting ### TLS fails - DNS A record wrong - ports 80/443 blocked - Traefik cannot write `acme.json` (volume permissions) ### Everything works locally but not on the domain - you routed the wrong hostname in Traefik labels - you have multiple routers conflicting ### Supabase feels slow - your VPS is under-sized - your disk is slow (common on low-end instances) - you need more RAM for Postgres caches ## Internal links (recommended next reads) - Comparison: `/alternatives/vercel` - Tutorial: `/deploy/nextjs` - ServerCompass template: https://servercompass.app/templates/supabase?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta --- **Related in the StoicSoft network** If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, [StoicVPS](https://stoicvps.com) is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety. --- ## How to Self-Host Uptime Kuma on a VPS (Production Setup) Source: https://deploytovps.com/blog/self-host/uptime-kuma Published: 2026-03-06 Tags: uptime-kuma, monitoring, self-host, docker-compose Run Uptime Kuma for self-hosted uptime monitoring. Beautiful status pages without monthly fees. Uptime Kuma is a self-hosted monitoring tool with a clean UI. Monitor HTTP, TCP, DNS, and more. Beautiful status pages included. No per-monitor pricing. No SaaS fees. Just your server cost. ## What you will have at the end - Uptime Kuma running at `https://status.your-domain.com` - Automatic SSL via Traefik - Persistent monitoring data - Public status page ready to share ## Step 1: Prepare the server ```bash sudo apt update && sudo apt upgrade -y ``` Install Docker if needed: ```bash curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker ``` ## Step 2: Create directory structure ```bash mkdir -p ~/apps/uptime-kuma/data cd ~/apps/uptime-kuma ``` ## Step 3: Create docker-compose.yml ```yaml services: traefik: image: traefik:v2.11 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped uptime-kuma: image: louislam/uptime-kuma:1 restart: unless-stopped volumes: - ./data:/app/data environment: - TZ=UTC labels: - traefik.enable=true - traefik.http.routers.uptime-kuma.rule=Host(`status.your-domain.com`) - traefik.http.routers.uptime-kuma.entrypoints=websecure - traefik.http.routers.uptime-kuma.tls=true - traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt - traefik.http.services.uptime-kuma.loadbalancer.server.port=3001 volumes: letsencrypt: ``` ## Step 4: Deploy No [environment variables](https://servercompass.app/features/env-vars-editor) needed. Uptime Kuma handles setup through the UI. ```bash docker compose up -d ``` ## Step 5: Create admin account Visit `https://status.your-domain.com` and create your admin account on first visit. This becomes your login for the dashboard. ## Step 6: Add your first monitor 1. Click "Add New Monitor" 2. Select monitor type (HTTP, TCP, DNS, etc.) 3. Enter the URL or host to monitor 4. Set check interval (default: 60 seconds) 5. Configure notifications (optional) ## Step 7: Set up status page 1. Go to "Status Pages" in the sidebar 2. Click "New Status Page" 3. Add monitors to display 4. Customize branding 5. Share the public URL ## Notification options Uptime Kuma supports 90+ notification services: - Slack, Discord, Microsoft Teams - Email (SMTP) - Telegram, Pushover - PagerDuty, Opsgenie - Custom webhooks Configure in Settings > Notifications. ## Backup strategy Uptime Kuma uses SQLite. The data directory contains everything. ```bash #!/bin/bash # Daily backup script BACKUP_DIR=/backups/uptime-kuma mkdir -p $BACKUP_DIR # Stop for consistent backup (brief downtime) docker compose stop uptime-kuma # Copy data tar -czf $BACKUP_DIR/uptime-kuma-$(date +%Y%m%d).tar.gz ~/apps/uptime-kuma/data # Restart docker compose start uptime-kuma ``` For zero-downtime, copy the SQLite file and verify integrity: ```bash sqlite3 ~/apps/uptime-kuma/data/kuma.db ".backup '/backups/kuma-backup.db'" ``` ## Troubleshooting ### Cannot access dashboard after setup - Check if container is running: `docker compose ps` - Verify Traefik logs: `docker compose logs traefik` - Confirm DNS A record points to your VPS IP ### Monitors show as down incorrectly - Check if the monitored service is accessible from your VPS - Verify firewall allows outbound connections - Test manually: `curl -I https://example.com` ### Notifications not sending - Verify notification service credentials - Check Uptime Kuma logs: `docker compose logs uptime-kuma` - Test notification in Settings > Notifications > Test ### Status page not loading - Ensure the status page is published - Check if the slug matches the URL - Verify Traefik is routing correctly ## Internal links - Tutorial: [Deploy Docker Compose to VPS](/deploy/docker-compose) - Tutorial: [Self-Host n8n](/self-host/n8n) - Comparison: [Pingdom Alternatives](https://deployhandbook.com/alternatives/pingdom) - ServerCompass: [One-click Uptime Kuma deployment](https://servercompass.app/templates/uptime-kuma?utm_source=deploytovps&utm_medium=referral&utm_campaign=cta) --- ## Docker app upgrades need version-hop checkpoints when backups cannot restore across old releases Source: https://deploytovps.com/blog/guide/docker-app-upgrades-version-hop-checkpoints Tags: docker, home-assistant, proxmox, migration, self-hosting Running Docker apps at home or on a VPS feels low-risk until you try to upgrade and discover your backup is too old to bridge the gap. Planning version-hop checkpoints before you need them is the difference between a clean migration and a broken stack. Running Docker apps at home or on a VPS feels low-risk until you try to upgrade and discover your backup is too old to bridge the gap — and you are staring at a broken stack with no clean path forward. ## Why upgrades break without checkpoints Most self-hosted apps handle schema migrations internally on first boot after an upgrade. That works fine when you move one version at a time. It stops working when you skip too many releases, because migration scripts are often written to assume the previous version's schema, not a version from two years ago. Home Assistant is a clear example. If you have a Docker install from 2022 and your latest backup is equally old, moving straight to the current release risks corrupting HACS extensions and Zigbee device state. The backup exists, but it cannot be applied to the new version without first walking through intermediate releases. That is not a bug — it is how incremental migration works. The gap is the problem. The same pattern appears with Nextcloud, Immich, and almost any app that manages its own database. Nextcloud explicitly documents major-version upgrade paths and warns against skipping them. Immich has had breaking changes between minor versions during rapid development phases. Navidrome stores a SQLite database that can accumulate structural assumptions. When you skip checkpoints, you are betting that no migration script along the way required the previous step. ## The Proxmox helper-script trap Proxmox helper scripts make it easy to spin up containers for Immich, Nextcloud, Navidrome, and Crafty with a single command. That convenience creates a different problem: the scripts configure things for you, and if you did not read the output carefully, you may not know where credentials were written or how Tailscale was set up inside the container. A common scenario: a Proxmox user loses access to the PVE web UI and cannot locate generated Crafty credentials. The Tailscale node inside the container is not reachable because the VM's network configuration changed. The container is running, but access to it is effectively lost. This is not a disaster on its own — it becomes one when you also need to upgrade the app inside that container, because the upgrade path starts with having a known-good backup and confirmed access. Before running any helper script, write down the container ID, note where credentials were printed, confirm Tailscale connectivity, and take a Proxmox snapshot immediately after first login. That snapshot is your checkpoint zero. ## Planning checkpoints for a growing homelab stack A new homelab operator with NPM, Portainer, Jellyfin, Vaultwarden, Tailscale, and arr apps running already faces a real architectural question when adding Immich: how do I upgrade any of this in six months without pain? The answer is checkpoints built into your routine, not patched in after the fact. **Pin your image tags.** Running `image: immich-server:latest` in your Compose file means every `docker compose pull` pulls whatever the maintainers released today. If a breaking change ships, you find out the hard way. Pin to a specific version tag (`v1.106.1`) and upgrade deliberately. When you are ready to upgrade, check the release notes for each version between your current tag and the target. If the project calls out a required intermediate step, do it. **Take volume backups before pulling new images.** The pattern is simple: stop the container, back up its data volume, pull the new image, start the container, verify the app loads and your data is intact. If verification fails, restore the volume and roll back to the previous image tag. This is especially important for database-backed apps like Immich, Nextcloud, and Vaultwarden. **Document your current versions.** A simple text file or a note in your homelab wiki with current image tags and the date you last upgraded is enough. You do not need a sophisticated system — you need to avoid the situation where you cannot remember what version you are on. Tools like [ServerCompass](https://servercompass.app) let you see all your running services at a glance, which makes it easier to audit what is deployed and catch apps that have drifted far behind current releases. **Test your backups.** A backup that has never been restored is a backup you cannot trust. Periodically do a test restore into a scratch container on a non-production network. For Vaultwarden, this is especially worth doing — your password vault is not a good place to discover that restores fail. ## What to do if you are already stuck If you are in the situation where your Home Assistant backup is too old to cleanly restore to the current release, or your Proxmox container is running but inaccessible, the path forward is methodical. For version-gap upgrades: find the release history for your app and identify intermediate versions. Pull each intermediate image tag, start the container, let it run migrations, verify, stop, then move to the next step. Do not skip steps even if the app appears to start — the migration may have partially completed. For lost credentials: check the helper script's source or the project's documentation for default credential locations. For Crafty, credentials are typically written to a file inside the container at first boot. If the container is still running, `docker exec` into it to retrieve them. If the container is gone, restore from your most recent snapshot. For broken Tailscale access inside a VM: log into the Tailscale admin console, find the stale node, remove it, then re-authenticate from inside the container. If you cannot get into the container, use the host's console access (Proxmox web UI or VNC) rather than Tailscale. ## Checklist - Pin Docker image tags to specific versions in your Compose files; never rely on `latest` for production services - Before every upgrade, stop the container, back up its data volume, then pull the new image - Check release notes for each version between your current tag and target — note any required intermediate stops - Take a Proxmox snapshot immediately after spinning up a new container via helper script - Write down generated credentials and Tailscale node names before closing the terminal - Keep a simple record of current image versions and last-upgraded dates for each service - Test a backup restore into a scratch container at least once per service before you need it in an emergency - Use a service inventory tool like [ServerCompass](https://servercompass.app) to keep visibility over what is running and catch apps that have fallen far behind --- ## First-time NAS builds need appdata, backup, and OS-placement guardrails before services pile up Source: https://deploytovps.com/blog/guide/first-time-nas-build-appdata-backup-os-placement Tags: nas, unraid, backups, docker, homelab Before your first NAS box even arrives, a handful of structural decisions will determine whether your homelab stays manageable or becomes a recovery project. Get OS placement, appdata protection, and backup tiers sorted upfront and the rest is incremental. Before your first NAS box even arrives, a handful of structural decisions will determine whether your homelab stays manageable or becomes a recovery project. Get OS placement, appdata protection, and backup tiers sorted upfront and the rest is incremental. ## Where the OS should (and should not) live This is the question that trips up most beginners: does the operating system go on a dedicated drive, one of the array drives, or a USB stick? On Unraid, the OS boots from a USB drive by design. That USB is critical — lose it and you lose your array configuration. Keep a spare clone of the USB somewhere safe and do not rely on the original as your only copy. On a two-node HP MicroServer setup or any platform where you have more flexibility, putting the OS on a dedicated SSD outside the array is the cleaner long-term choice. It keeps the OS footprint separate from your data drives and makes reinstalls or experiments (say, trying ZFS or a BSD-based system on one node) non-destructive to the array. A common mistake is mixing the OS onto the same drive that holds media or personal data. When something goes wrong — and eventually it does — you want to be able to wipe and reinstall the OS without touching data. Keep the boundary clean from day one. ## Appdata and cache: protect these before anything else When you run Docker containers on Unraid, each container writes its working state to an appdata share. This includes database files, config, credentials, and state that cannot be regenerated from the internet. If that share lives only on the array and a parity rebuild goes sideways, or if the wrong drive fails at the wrong time, you lose container configuration you spent hours tuning. The practical answer is to put appdata on a cache drive (an SSD separate from the spinning array), then configure Unraid's mover to leave appdata on cache permanently rather than migrating it to the array. This gives containers fast local I/O and keeps the data on a drive that is not subject to array rebuild operations. Cache itself then needs its own protection. A single cache SSD is a single point of failure. For anything you care about, a cache pool with two SSDs in BTRFS RAID1 (Unraid's native approach) is the minimum. The redundancy is not glamorous, but it prevents an SSD failure from wiping every container's working state simultaneously. ## Backup tiers: local, offsite, and what actually counts as a backup A popular beginner setup pairs a UGREEN 6-bay NAS running Plex and Docker with rclone pushing encrypted snapshots to Backblaze B2, plus a Synology as a local backup target. That is a reasonable three-tier approach: primary NAS, local secondary (Synology), and cold offsite (B2). The question is what you are actually backing up and on what schedule. Parity on Unraid protects against a single drive failure — it is not a backup. If you delete a file, overwrite it with garbage, or suffer a ransomware event that encrypts your array, parity does nothing. Backups need to be separate, versioned copies that are not live-writable from the NAS itself. For most homelab operators the practical split is: - **Appdata and config**: back up daily, local and offsite. This is small in size but irreplaceable. - **Personal documents and photos**: versioned, offsite. Backblaze B2 with rclone is a solid choice and costs almost nothing at typical personal-data scale. - **Media (Plex library, rips, downloads)**: local copy only is often acceptable, since media can usually be re-acquired. Offsite backup for a multi-terabyte Plex library gets expensive fast. Test restores. A backup you have never restored from is an untested assumption. ## Which Docker services to run first With the structural pieces in place, the natural next question is: what do I actually deploy? Start with services that are stable, well-documented, and give you operational insight into the box itself. Pi-hole and AdGuard Home are both good early installs — they are lightweight, immediately useful network-wide, and will survive a container restart without data loss if appdata is properly protected. A VPN container (WireGuard or Tailscale) is worth adding early so you have secure remote access before you open anything to the public internet. A monitoring agent or dashboard comes next. Tools like Netdata, Grafana with Prometheus, or a simpler status page let you see CPU, RAM, disk, and network trends over time. ServerCompass lets you see all your running services at a glance across machines, which is useful if you are already managing two nodes or planning to expand. Knowing what is normal before something goes wrong makes diagnosing problems much faster. Heavier services — Jellyfin, Plex, Sonarr/Radarr stacks — should come after the foundations are solid. They are more demanding, have more moving parts, and are more painful to reconfigure if your appdata setup is wrong. Get the backup loop working and verified first. For a two-node setup, Syncthing is a natural choice for keeping personal data synchronized between nodes without depending on a cloud intermediary. It pairs well with an offsite B2 backup: one node has the live copy, the other node has a local replica, and B2 has the versioned offsite copy. ## Checklist - Keep the Unraid USB boot drive cloned; store the clone somewhere the NAS cannot reach it - Put the OS on a dedicated drive separate from data drives when the platform allows it - Move appdata to a cache SSD and configure it to stay on cache permanently - Use at least two SSDs in a cache pool (BTRFS RAID1) to protect appdata from a single-drive failure - Define three backup tiers: primary NAS, local secondary, and offsite (e.g., Backblaze B2 via rclone) - Back up appdata and personal documents daily; media-only-local is usually acceptable - Run a test restore before you assume any backup works - Start with lightweight foundational services (Pi-hole, VPN, monitoring dashboard) before adding heavy media stacks --- ## Hybrid gaming PCs turned home servers need a safe host and VM boundary before Docker apps go 24/7 Source: https://deploytovps.com/blog/guide/gaming-pc-home-server-host-vm-boundary-docker Tags: homelab, docker, proxmox, gpu-passthrough, home-server Running a gaming PC and a 24/7 home server on the same hardware is possible, but only if you decide up front which layer owns the host, the GPU, power management, and Docker. Getting that boundary wrong means your game sessions interrupt your services — or your services prevent clean shutdowns. Running a gaming PC and a 24/7 home server on the same hardware is possible, but only if you decide up front which layer owns the host, the GPU, power management, and Docker. Getting that boundary wrong means your game sessions interrupt your services — or your services prevent clean shutdowns. ## The problem with all-in-one boxes Homelab forums are full of posts from people who have one powerful machine — often a desktop with a discrete GPU — and want to get maximum use out of it. The appeal is obvious: one box, one electricity bill, one set of hardware to maintain. The challenge is that gaming workloads and server workloads have opposing requirements. Gaming needs low-latency GPU access, responsive power states, and the freedom to reboot when a driver update or game patch demands it. A 24/7 server needs stable uptime, predictable network addresses, and services that survive host reboots without manual intervention. These two goals are not incompatible, but they do not coexist automatically. You have to architect the boundary deliberately. Recent homelab threads show a common pattern: someone installs bare-metal Linux thinking they will game natively and run Docker on the side, then discovers that Docker container restarts, network bridge conflicts, and manual service management collide with their gaming workflow. Others try Proxmox but find GPU passthrough blocked by their hardware, leaving them with a hypervisor they cannot fully use. Some land on Unraid hoping it handles everything, then realise that streaming to Moonlight clients across the house and running a Docker stack simultaneously puts unanticipated load on the same system. ## Deciding which layer owns what Before you install anything, answer these six questions on paper: **1. Who owns the host?** If you want to game on bare metal, the host OS is your daily driver and Docker runs directly on it. If you want strict isolation, a Type-1 hypervisor like Proxmox owns the host and your gaming environment lives in a VM — but this requires GPU passthrough to work, which is not guaranteed on all hardware. **2. Who owns the GPU?** GPU passthrough to a VM is the cleanest isolation but requires an IOMMU group that cooperates, a second GPU for the host (or iGPU), and VFIO configuration. Without those prerequisites, bare-metal gaming with Docker on the side is your realistic option. **3. Who owns power management?** If your gaming PC suspends to save power overnight, your Docker containers go down with it. You need to decide whether the machine runs 24/7 or whether your services can tolerate scheduled downtime. Hybrid approaches — wake-on-LAN, suspend inhibitors for specific containers — are possible but add complexity. **4. Who owns the network?** Docker's default bridge networking works fine in isolation, but gaming streaming protocols (Moonlight/Sunshine) and Docker port mappings can conflict. Decide early whether Docker services get a dedicated VLAN, use host networking, or sit behind a reverse proxy. **5. Who owns remote access?** Moonlight for game streaming, SSH for administration, and a VPN for secure remote access are three different channels with different requirements. They should not share the same entry point. **6. Who owns recovery?** If the host kernel panics or the Docker daemon crashes, how do services come back? `docker restart unless-stopped` handles the daemon case. Host crashes require BIOS auto-power-on or a watchdog. Know your recovery path before you deploy anything you care about. ## Practical architectures that actually work Once you have answered those six questions, a small number of architectures emerge as genuinely viable. **Bare-metal Linux host with rootful Docker.** The host runs a desktop environment for gaming. Docker runs directly on the host as a separate concern. Services start on boot via `docker compose up -d` and a systemd service or `--restart unless-stopped`. The downside is that host reboots for kernel/driver updates require all containers to restart — which they do automatically if configured correctly. This is the lowest-friction setup if GPU passthrough is not available. **Proxmox host with GPU passthrough to a gaming VM.** The hypervisor owns bare metal. A Windows or Linux gaming VM gets the dedicated GPU passed through. A separate Linux VM or LXC container runs the Docker stack and stays up regardless of what the gaming VM does. This is the cleanest isolation but requires hardware that cooperates with IOMMU grouping. Verify passthrough feasibility before committing to this path. **Unraid with a gaming VM and Docker.** Unraid combines array storage, VM management, and a Docker UI. It is approachable for people who want a GUI but it is not a free choice — the licensing cost and the Unraid-specific conventions (dockers run as root by default, array spin-up affects latency) should factor into your decision. For the Moonlight streaming use case specifically, Sunshine needs to run inside the VM with GPU access, not at the Unraid host level. Across all three architectures, a tool like [ServerCompass](https://servercompass.app) is useful once services are running — it lets you see all your running services at a glance across containers, VMs, and hosts without SSH-ing into each one individually. ## Setting up the Docker stack for 24/7 reliability Regardless of which host architecture you choose, Docker services running 24/7 need a few non-optional configurations. **Restart policies.** Every service in your compose file should have `restart: unless-stopped` (or `always` if you want it to come back even after a manual `docker stop`). Without this, a host reboot leaves your services down permanently until you SSH in. **Health checks.** Add a `healthcheck` block to containers that serve traffic. Docker will restart unhealthy containers automatically, which handles most soft failures without manual intervention. **Named volumes over bind mounts for critical data.** Bind mounts to host directories are convenient but they tie container data to host filesystem paths. Named volumes are slightly more portable and survive container recreation without path confusion. **Log limits.** Docker's default JSON file logging driver has no rotation configured by default. A long-running container on a machine that also runs games (which generate their own logs and crash dumps) will eventually fill the disk. Add `--log-opt max-size=10m --log-opt max-file=3` or set it globally in `/etc/docker/daemon.json`. **Reverse proxy for HTTP services.** Running Traefik or Caddy as a reverse proxy means you expose one port (443) instead of one port per service. This simplifies firewall rules, enables HTTPS, and avoids port collisions with gaming streaming ports. ## Checklist - Confirm GPU passthrough is feasible on your hardware (IOMMU groups, second display output) before choosing a hypervisor architecture - Choose one host architecture (bare-metal + Docker, Proxmox + VMs, Unraid) and stick to its conventions rather than mixing approaches - Set `restart: unless-stopped` on every Docker service that must survive reboots - Assign static IPs or DNS aliases to Docker services — do not rely on dynamic container IPs in your gaming streaming or VPN config - Separate streaming ports (Moonlight/Sunshine) from Docker service ports at the firewall level - Decide your power management policy before deploying: suspend-to-RAM and 24/7 services do not coexist without explicit inhibitors or scheduled wake - Add log rotation to Docker daemon config to prevent disk fill on a machine that also runs games - Use a dashboard like [ServerCompass](https://servercompass.app) to monitor all running services without logging into each host individually --- ## Home Assistant recovery needs proof that remote restart, updates, and encrypted backups can restore without local hands Source: https://deploytovps.com/blog/guide/home-assistant-recovery-proof-remote-restart-backups Tags: home-assistant, backup, restore, remote-access, updates Remote Home Assistant installs can strand you after a restart or update if you haven't verified your recovery path ahead of time. Here's how to build a setup where encrypted backups, remote access, and boot media all cooperate before you need them. Remote Home Assistant installs can strand you after a restart or update if you haven't verified your recovery path ahead of time — and recent Reddit threads make clear that most people discover the gaps only after they're already locked out. ## The two failure modes that keep appearing Two patterns show up repeatedly in Home Assistant forums. The first: a remote HAOS install on a Raspberry Pi doesn't return after a restart triggered through Nabu Casa. The Pi is online at the network level, but Home Assistant never comes back up. HACS and integrations like Reolink stay broken, logs don't point anywhere useful, and the only fix is a physical power cycle. The owner is either onsite or calls someone who is. There's no remote fix. The second pattern is worse: a user hits HAOS corruption — a second time in two years — and discovers their backups are encrypted with a key they don't have. The backup file exists. It's readable. But restore is blocked until the encryption password is found. If it's not stored anywhere accessible, the backup is useless. A longer-form post describing years of SD card corruption, overheated USB boot media, forgotten off-host backups, and debugging dead ends before reaching a stable setup rounds out the picture. The instability isn't a fluke — it's the default outcome when Home Assistant runs on cheap boot media without a tested recovery path. ## Why "I have backups" is not the same as "I can restore" Backup files and restore capability are different things. A backup gives you the data. Restore capability means you can get a fresh HAOS install running, connect it to that backup, supply the correct encryption key, and have the system come up — without anyone being physically present. Each of those steps can fail independently: - The boot media fails and you don't have an image to reflash remotely. - The encrypted backup exists but the key isn't documented anywhere accessible. - The restore completes but remote access (Nabu Casa, Tailscale, or a VPN) doesn't initialize until someone manually checks a checkbox in the UI. - The integration state restores but HACS extensions that depended on a specific HA version need manual reinstall. The Raspberry Pi case above is specifically a boot hang — the OS loads enough to be on the network but HAOS itself never finishes starting. Without a serial console, a remote KVM, or a watchdog that power-cycles the Pi on failure, there is no remote recovery path for that failure mode. ## Building a setup that can recover without you being there The goal is to eliminate the class of failures where your only option is "go there in person." That means layering several things: **Boot media that doesn't fail silently.** SD cards are the most common cause of corruption. Moving to a USB SSD or NVMe (via a Pi USB adapter) reduces that risk substantially. The longer-form forum post describes years of SD-card grief followed by stability after switching. If the Pi doesn't support USB boot by default, enable it in the bootloader before you deploy. **A power cycle path that doesn't require a person.** A smart plug on the Pi's power supply gives you a hard reset from anywhere. This handles the specific case where HAOS hangs on boot and won't respond to anything — pull power, restore power, check again. It's not elegant but it works for the most common remote recovery scenario. **Encryption key storage that isn't just in your head.** If HAOS backups are encrypted, the key needs to be stored somewhere you can retrieve it remotely — a password manager, a secrets store, or a documented location that's accessible from your phone. "I remember it" is not sufficient. The correct behavior when setting up encrypted backups is to write down the key immediately and verify you can find it. **Off-Pi backup storage.** Backups stored only on the Pi are lost when the Pi's media fails — which is the exact scenario you're trying to recover from. Set up external backup targets: a network share, an S3-compatible bucket, Google Drive via the Home Assistant Google Drive backup integration. Verify the backup appears there before assuming it works. **Tested restore, not assumed restore.** The only way to know a backup restores correctly is to do it on a test instance. Spin up a second HAOS install in a VM, pull the encrypted backup, supply the key, and walk through the restore. If it works, document exactly what you did. If it doesn't, fix it now — not after production fails. For anyone managing multiple services on the same host, [ServerCompass](https://servercompass.app) lets you see all your running services at a glance, which helps catch when Home Assistant has gone quiet while everything else on the box is still up — a useful early signal that you have a HAOS-specific hang rather than a network or host problem. ## Checking remote access independently of Home Assistant One subtlety in the Nabu Casa restart case: if you triggered the restart through Nabu Casa, and then Nabu Casa is unavailable because HA hasn't fully started, you're in a loop where your remote access tool depends on the thing that failed. Layering a second remote access path — Tailscale at the OS level, an SSH server on the Pi independent of HAOS, or a Cloudflare tunnel — means you can still reach the Pi even when the HAOS layer is down. SSH to the Pi's OS lets you check whether the HAOS VM (if running supervised) or the HAOS service is actually up, restart it, or pull logs without needing the web UI. This is the difference between "I can diagnose" and "I have no visibility." Updates are another trigger for hangs. HAOS updates sometimes require a full reboot of the OS layer. If the Pi's bootloader or media is marginal, updates are the stress test that reveals the problem. Running updates only when you have a few minutes to watch is reasonable; scheduling them for 3am when no one is available is not. ## Checklist - Replace SD card boot media with a USB SSD or NVMe adapter before deploying remotely - Add a smart plug to the Pi's power supply for hard-reset capability from anywhere - Store your HAOS backup encryption key in a password manager or documented remote-accessible location immediately after enabling encryption - Configure at least one off-Pi backup destination (network share, S3 bucket, or Google Drive) and confirm a backup appears there - Add a second remote access path at the OS level (Tailscale, SSH, or Cloudflare tunnel) that works even when HAOS is down - Do a restore drill on a test HAOS instance using your encrypted backup and key before you need it in production - Monitor HAOS service status separately from network connectivity so a HAOS hang is visible without logging into the UI - Schedule updates only when you can watch them, not unattended overnight --- ## Beginner homelabs need an app-first plan before buying NAS, DAS, and always-on mini-PC hardware Source: https://deploytovps.com/blog/guide/homelab-app-first-plan-before-hardware-shopping Tags: homelab, nas, jellyfin, immich, beginner Most homelab beginners start by browsing hardware — NAS boxes, mini-PCs, DAS enclosures — before they have a clear picture of what apps they actually want to run. Locking down your app list first changes every hardware decision that follows. Most homelab beginners start by browsing hardware — NAS boxes, mini-PCs, DAS enclosures — before they have a clear picture of what apps they actually want to run. Locking down your app list first changes every hardware decision that follows. ## Start with the app list, not the spec sheet The most common pattern among people setting up their first homelab is anchoring on hardware. They see a deal on a Buffalo NAS or an N150 mini-PC and start reverse-engineering an app list to justify the purchase. This almost always leads to either over-buying or hitting a wall later — like discovering a NAS with a slow ARM SoC can't transcode Jellyfin streams in real time, or that Pi-hole needs a constantly-running host that a NAS might not provide cleanly. The better approach: write down every app you want to run before pricing a single device. Common beginner combinations look like this: - **Nextcloud** — file sync and sharing, needs persistent storage and reasonable RAM - **Jellyfin** — media server, needs CPU or GPU headroom for transcoding - **Pi-hole** — network-level ad blocking, needs an always-on low-power host - **Immich** — photo library, needs storage, RAM, and optional GPU for ML features Those four apps alone tell you a lot. Pi-hole and Nextcloud can coexist on modest hardware. Jellyfin and Immich together push you toward something with more CPU and expandable storage. Once the list exists, the hardware shopping becomes mechanical. ## What an old PC can actually do Running Nextcloud, Jellyfin, and Pi-hole on an old desktop PC is a completely reasonable starting point — in many cases it's the best one. Old x86 hardware almost always outperforms purpose-built NAS boxes for compute-heavy tasks. An old i5 or i7 with 8–16 GB of RAM will handle Jellyfin transcoding without breaking a sweat, whereas a NAS running a quad-core ARM chip might stutter on a single 4K stream. The practical concerns with an old PC are power draw and noise. A full desktop pulling 65–100 W around the clock adds up on an electricity bill compared to a mini-PC at 10–15 W. If your app list is light — Pi-hole, Nextcloud for a single user, and occasional Jellyfin use — the power argument for a mini-PC gets stronger. If you're running Jellyfin for a household and want Immich for ML-assisted photo search, the old PC's raw power may be worth the wattage. Storage is the other consideration. Old PCs usually have multiple SATA bays, which gives you room to grow. Before buying a separate NAS or DAS, check how many drives you can actually fit in the case you already have. ## Always-on mini-PCs and DAS expansion An N150 (or similar low-power Intel N-series) mini-PC is a popular anchor for an always-on hub. These machines sip power, run a full Linux stack, and handle Pi-hole, Nextcloud, and light Jellyfin work without issue. The sanity-check question to ask before committing: does this chip have enough headroom for everything I want to run simultaneously? Mini-PCs typically ship with one or two M.2 slots and one 2.5-inch bay — enough for an OS drive and one data drive. When your media library or photo archive outgrows that, a DAS (Direct Attached Storage) enclosure connects via USB 3 or Thunderbolt and adds several drive bays without replacing the host. The compatibility check here is straightforward: confirm the DAS enclosure supports the drive form factor (3.5-inch vs 2.5-inch), verify the interface speed matches your use case, and format the drives in a filesystem your OS handles natively (ext4 on Linux, which is the standard for most self-hosted stacks). For Jellyfin specifically, the drives don't need to be anything exotic — standard 7200 RPM spinning disks handle sequential media reads without issue. The bottleneck is almost always CPU (for transcoding) or network (for simultaneous streams), not drive speed. Once you have multiple services running, keeping track of what's healthy gets harder. [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — CPU, memory, and uptime per process — without SSH-ing into the box every time you want to check on something. ## Immich and Buffalo NAS: matching the hardware to the workload Immich is a self-hosted Google Photos alternative with a solid feature set, but it's not a lightweight app. A minimal Immich install needs around 2–4 GB of RAM for the app stack alone. Optional ML features (face recognition, CLIP-based search) add GPU or CPU overhead on top of that. A Buffalo NAS running a proprietary OS (like Buffalo's NAS Navigator stack) is not the right host for Immich. These devices are built for SMB file sharing, not running Docker containers with ML workloads. If you already have a Buffalo NAS for storage, the practical setup is to run Immich on a separate compute host — the old PC or the mini-PC — and point Immich's library at the NAS over NFS or SMB. If you're starting from scratch and Immich is the primary use case, a small x86 machine running Debian or Ubuntu with Docker is the path of least resistance. A used mini-PC or SFF desktop in the $100–200 range covers the compute. Storage can be internal or a DAS once the library grows. Network experience matters less than people think at the beginner stage. You need a router that supports static DHCP leases (so your server always gets the same IP) and basic port forwarding if you want remote access. That's a five-minute task on almost any consumer router. The Immich documentation walks through the rest. ## Checklist - Write down every app you want to run before evaluating any hardware - Map each app to its CPU, RAM, and storage requirements — identify the most demanding one - Check whether an old PC already in hand covers the requirements before buying new hardware - For an N150 or similar mini-PC, verify it can handle all target services running simultaneously - When expanding storage, confirm DAS drive compatibility (form factor, interface, filesystem) before purchasing - For Immich or other ML-assisted apps, check RAM requirements and whether the host supports Docker - Do not run Immich or Jellyfin directly on a NAS running proprietary firmware — use a separate compute host and mount the NAS storage - Once services are running, use a dashboard like [ServerCompass](https://servercompass.app) to monitor health without constant SSH sessions --- ## Homelab consolidation decisions now mix electricity costs, Immich storage, and future migration risk Source: https://deploytovps.com/blog/guide/homelab-consolidation-electricity-costs-immich-storage Tags: homelab, power-cost, immich, docker, storage Running multiple rack servers or stacking new services on aging hardware is forcing self-hosters to weigh power bills, storage safety, and the hidden cost of migrating everything later. Here is how to think through the decision before you buy or consolidate. Running multiple rack servers or stacking new services on aging hardware is forcing self-hosters to weigh power bills, storage safety, and the hidden cost of migrating everything later. ## The power bill problem with split rack setups A common pattern in homelabs that have grown organically: three rack servers, each handling a narrow slice of work. One runs the gateway stack — Docker, Nginx Proxy Manager, Cloudflare tunnels. Another holds Nextcloud and test storage. A third carries media workloads. Each machine idles most of the day but draws a full server's worth of standby power around the clock. When electricity costs rise, this split makes less and less sense. Three servers in a rack can easily pull 200–300 W combined at idle, depending on age and efficiency. Over a year, that is real money — often enough to offset the cost of a newer, more efficient tower that could run all those workloads in a single box. The consolidation calculus is not just about wattage, though. It also touches on failure domains. If one of three rack servers dies, only a portion of your services go down. If a single consolidated machine dies, everything stops. That trade-off is worth naming explicitly before you commit to either direction. A practical first step: measure actual draw with a power meter, not TDP specs. Rack servers from even five years ago often have far worse idle efficiency than modern tower CPUs or mini PCs. Once you have real numbers, the break-even point on new hardware becomes much easier to calculate. ## When to pause before adding Immich Immich has become one of the most popular self-hosted photo solutions, but it is also one of the most storage-hungry services you can run. A new homelabber who has already deployed Debian, Docker, NPM, Portainer, Jellyfin, Vaultwarden, Tailscale, and the arr stack on a repurposed laptop faces a reasonable question: is now the right time to add Immich? The honest answer is: not until storage and migration plans are settled. Immich writes a lot. It generates multiple thumbnail sizes, transcodes video, indexes faces and objects, and keeps a Postgres database that grows alongside your library. If your storage situation is temporary — a single internal drive, no backups, or hardware you plan to replace — adding Immich means doing a painful migration later. Photo libraries are among the harder things to move because the database references absolute paths and the machine-learning metadata is not trivially portable. The safer sequence is: 1. Decide on your storage hardware (dedicated NAS, external USB array, ZFS pool). 2. Set up at least one backup target before ingesting photos. 3. Then install Immich pointed at stable storage from day one. Skipping step 1 or 2 is how people end up with thousands of photos in a database that lives on a dying drive with no offsite copy. ## Always-on vs. spin-down disks for photo storage A related question comes up regularly in Immich communities: should the disks holding your photo library stay always on, or should they spin down and only wake for backup and viewing? The practical answer depends on how you use Immich. If you access the app daily — browsing the timeline, sharing albums, reviewing new uploads — always-on storage makes sense. Spin-up latency is noticeable when you open the app and the library is on a cold disk, and frequent spin-up/spin-down cycles add wear for certain drive types. If the library is archival — ingested once, rarely browsed, backed up nightly — spin-down is fine. Configure your OS or NAS to park the disks after 20–30 minutes of inactivity. Just make sure Immich's scheduled jobs (nightly thumbnail generation, machine learning passes) are not silently failing because the disks are asleep when the jobs fire. One useful operational check: log the Immich job scheduler output and verify that background tasks actually complete. It is easy to assume everything is running when the web UI looks fine, while background processes are quietly timing out because storage is unavailable. ServerCompass lets you see all your running services at a glance, including container health and uptime, which makes it easier to spot when an Immich container has restarted unexpectedly or a background job has stalled. ## Planning for the migration you will eventually do Every homelab evolves. The hardware you run today will be replaced, and the services you add now will need to move. The question is whether you make that migration easy or painful. A few decisions at setup time pay large dividends later: **Use named Docker volumes, not bind mounts to machine-specific paths.** Bind mounts like `/home/user/immich-data` are harder to move than a volume reference. If you do use bind mounts, keep the paths consistent and document them. **Keep your compose files and `.env` files in version control.** A git repo with your entire stack definition means standing up on new hardware is a controlled process, not a reconstruction from memory. **Run regular export or dump jobs for databases.** Immich's Postgres database is the source of truth for your photo metadata. A nightly `pg_dump` to a separate location means a failed migration loses hours of data, not months. **Test your restore process before you need it.** Spin up a temporary Docker environment, point it at a backup, and verify Immich starts and the library looks correct. Most people skip this step and discover gaps only when the real migration is underway. Consolidating from three rack servers to a tower is a good opportunity to re-examine all of these. The migration is disruptive regardless, so treating it as a chance to fix configuration debt — inconsistent paths, undocumented volumes, missing backups — is worth the extra time. ## Checklist - Measure actual power draw with a meter before estimating consolidation savings — TDP specs are not idle consumption - Identify failure domain trade-offs: split hardware spreads risk, consolidated hardware reduces cost but concentrates failure - Settle on stable storage hardware and at least one backup target before installing Immich - Set Immich's data path to permanent storage from the first install — migrations are painful once the library grows - Decide on always-on vs. spin-down based on actual access frequency, not assumed patterns - Verify Immich's background jobs (thumbnails, ML, backups) complete successfully, especially if disks spin down - Keep compose files and `.env` configs in version control so new hardware deployments are reproducible - Run and test a `pg_dump` restore for Immich's Postgres database before you need it in an emergency --- ## Homelab hardware monitoring needs host-fault alerts before services start crash-looping Source: https://deploytovps.com/blog/guide/homelab-host-fault-alerts-before-service-crash-loops Tags: monitoring, homelab, nextcloud, lxc, alerts When a homelab operator lost two CPU cores, the first sign wasn't a hardware alert — it was Nextcloud crashing in a loop. This post walks through the layers of monitoring you need to catch host-level faults before your services become the canary. When a homelab operator lost two CPU cores, the first sign wasn't a hardware alert — it was Nextcloud crashing in a loop inside an LXC container, dumping logs, crashes, and snapshots until someone noticed something was wrong. That backwards diagnostic order — app failure before host failure alert — is a common homelab trap, and it's worth building your monitoring stack specifically to prevent it. ## Why services fail before hardware alerts fire Most homelab setups grow monitoring from the top down. You add Uptime Kuma to watch your services, maybe a Grafana dashboard for CPU graphs, and call it done. The problem is that host-level faults — dead CPU cores, degraded RAM, a failing NVMe — don't always produce an obvious signal on their own. The kernel keeps running. The hypervisor keeps scheduling. Proxmox reports the node as healthy. Then an LXC that depends on memory bandwidth starts misbehaving, and your first alert is a container restart loop rather than a hardware warning. The Nextcloud example is instructive. An LXC workload that does a lot of I/O and memory operations is exactly the kind of workload that will surface dead CPU cores through abnormal behavior, not through a clean kernel panic. By the time the logs are full of crash dumps, the underlying hardware has been broken for a while. You've lost the window where you could have responded proactively. The fix is to instrument the host layer first, and wire its fault signals into the same alerting pipeline your service monitors use. ## What host-level monitoring actually covers Host-level monitoring is different from service monitoring. Service monitoring asks "is Nextcloud responding?" Host-level monitoring asks "is the machine that runs Nextcloud in a state where it can reliably run anything?" For a Proxmox-based homelab, that means tracking: - **CPU core health** — not just utilization, but whether all expected cores are online and contributing normally. A sudden drop in available threads or an unusual scheduling imbalance is worth alerting on. - **Memory fault detection** — ECC error counters if your hardware supports it, or at minimum tracking whether the available memory pool matches expectations. Silent memory corruption is rare but catastrophic. - **Storage health** — SMART data for drives, especially reallocated sector counts and pending uncorrectable errors. A drive heading toward failure usually telegraphs it in SMART data weeks before it dies. - **Container and VM restart counts** — not just "is the service up" but "how many times has this container restarted in the last hour." Frequent restarts without external cause are a host-level signal, not just an app-level one. - **Thermal and power events** — throttling events, unusual CPU temperature trends, and unexpected reboots all show up at the host layer before they affect application behavior. The goal is to have a signal path where host fault → alert fires before host fault → app misbehaves → app alert fires. ## Building the alert pipeline across layers A practical homelab NOC for a multi-node Proxmox setup needs to think in layers. The builder designing a mobile-first NOC around Proxmox nodes, Linux hosts, Docker containers, SSL/domain expiry, storage history, and recovery visibility had the right mental model: each of those is a distinct layer, and alerts need to flow through all of them in a way that gives you causal context, not just a pile of notifications. Here's a layering approach that works: **Layer 1: Host health** — Use node_exporter (Prometheus) or a tool like Netdata on each Proxmox node. Collect SMART data via smartd or smartmontools. Alert on ECC errors, SMART thresholds, CPU core count changes, and sustained thermal throttling. These alerts should be your highest priority — they indicate the platform is unreliable. **Layer 2: Hypervisor and container runtime** — Proxmox's own API exposes VM and LXC state. A restart loop shows up here before it shows up in your service monitor. Wire container restart frequency into your alerting system. If a container restarts more than three times in thirty minutes without a manual trigger, that's an alert-worthy event. **Layer 3: Service availability** — Traditional uptime checks (HTTP, TCP, ping) live here. Tools like Uptime Kuma or Gatus work well. These are useful but should be the last layer you add, not the first. **Layer 4: Application-layer signals** — Log aggregation, error rate tracking, and response time histograms. Useful for diagnosing the nature of a problem once you know a problem exists. The key is that alerts from Layer 1 and Layer 2 should be wired into the same notification channel as Layer 3 — and they should carry enough context that you can see "CPU core went offline → Nextcloud LXC started crash-looping → Nextcloud HTTP check failed" as a single incident rather than three separate noise events. Tools like [ServerCompass](https://servercompass.app) give you a unified view of running services, storage status, and SSL expiry across your nodes, which helps with the correlation problem — you can see service state and host context in the same place instead of switching between four dashboards. ## Recovery visibility matters as much as fault detection One failure mode that monitoring guides underemphasize: knowing when recovery is complete. A Home Assistant user had an add-on update stuck for hours and was waiting to restore from backup. The problem wasn't just that the update failed — it was unclear when it was safe to intervene, and whether a partial restore had succeeded. Recovery visibility means: - Backup job completion alerts, not just backup failure alerts. Know when your last good backup was taken, not just when a backup failed. - Restore verification — after a restore, run a basic smoke test to confirm the service is actually functional, not just that the restore process exited zero. - Stuck process detection — a process that hasn't progressed in N minutes and hasn't exited is worth alerting on separately from a crashed process. - Clear "all clear" signals — your monitoring system should tell you when a node returns to a healthy baseline after a fault, not just when the fault started. Most homelab setups have decent fault detection and poor recovery visibility. The gap leaves operators in the position of manually poking at systems to figure out if they're actually healthy again. ## Checklist - Add host-level monitoring (node_exporter, Netdata, or similar) to every Proxmox node before relying on service-level uptime checks - Collect SMART data for all drives and alert on key thresholds (reallocated sectors, pending uncorrectable errors, temperature spikes) - Track container and LXC restart frequency — more than 2-3 restarts per hour without a manual trigger should fire an alert - Wire host-layer alerts into the same notification channel as service alerts so you can see causal chains, not isolated events - Monitor CPU core count and memory pool size for unexpected changes that don't produce an obvious kernel error - Set up backup completion alerts (not just failure alerts) and know the timestamp of your last verified good backup at all times - Add stuck-process detection for long-running jobs like updates, migrations, and restores - Use a unified dashboard (such as [ServerCompass](https://servercompass.app)) to correlate service state with host health without switching between multiple tools --- ## Homelab monitoring is expanding from uptime pages to traffic, assets, and recovery visibility Source: https://deploytovps.com/blog/guide/homelab-monitoring-beyond-uptime-traffic-assets-recovery Tags: monitoring, homelab, docker, alerts, networking A simple uptime page used to be enough for most homelab setups, but builders are now asking harder questions: is network traffic behaving normally, are Docker containers and NAS assets healthy, and what does the recovery path look like when something fails? This guide breaks down the four monitoring layers worth adding once your basic ping checks are in place. A simple uptime page used to be enough for most homelab setups, but builders are now asking harder questions: is network traffic behaving normally, are Docker containers and NAS assets healthy, and what does the recovery path look like when something fails? ## Beyond the Green Dot: What Uptime Checks Miss An uptime monitor tells you a service responded. It does not tell you whether it responded slowly, whether the traffic reaching it looks suspicious, or whether the disk it reads from is one write-error away from failing. For a basic Nginx reverse proxy or a static site, a ping-style check is fine. For a homelab running Jellyfin on OpenMediaVault, Proxmox nodes, multiple Docker stacks, and private services exposed through a VPN, a green dot is not enough signal. The gap becomes obvious the first time a service stays "up" while serving garbage — a misconfigured container that returns 200 for every request, or a NAS that is online but silently dropping writes because of filesystem errors. You only find out when you try to stream a file and it fails, or when you go to restore a backup and there is nothing there. ## Network Traffic Visibility One of the more honest questions floating around homelab communities is whether people actually run network intrusion detection — tools like Suricata, Snort, or Zeek — or whether most setups fly blind on traffic analysis. The honest answer is: most fly blind, and for a purely internal homelab with no externally exposed services, that is often acceptable. But the moment you punch holes in your firewall or run services accessible from outside your LAN, blind is a real risk. At minimum, you want flow-level visibility: which hosts are talking to which, in what volume, and to what external destinations. Full packet inspection with Suricata or Zeek is more useful but also more overhead. A practical middle ground is enabling NetFlow or sFlow on your router or switch and sending data to a lightweight collector like ntopng or Grafana with a flow plugin. You get anomaly detection without the CPU cost of deep packet inspection on every frame. For Docker-heavy homelabs, network visibility also means knowing which containers are making outbound connections and whether those connections are expected. A container that suddenly starts hitting external APIs it never touched before is worth an alert, even if the container itself is "up." ## Asset and Container Health Once a homelab grows past a handful of services, the monitoring surface expands fast. A useful NOC view for a mid-size homelab now includes: Synology NAS health (disk SMART data, volume status, temperature), Proxmox node load and VM status, Docker container restart counts and exit codes, website and private service availability, SSL certificate expiry, and domain renewal dates. None of these are exotic requirements. SSL expiry is a classic failure mode — a cert lapses, HTTPS breaks, and you spend an hour debugging what should have been a calendar reminder. Domain expiry is rarer but more catastrophic. SMART data on NAS disks gives you weeks of warning before a mechanical drive fails, if you are actually watching it. Storage history matters too. A NAS volume that is 40% full today and 60% full in three weeks is telling you something; a flat usage curve that suddenly spikes is telling you something different. Neither shows up on an uptime page. Tools like [ServerCompass](https://servercompass.app) let you see all your running services at a glance — Docker containers, websites, and private endpoints — with SSL expiry tracking built in, which covers several of these checks without requiring you to wire up separate monitors for each service type. ## Recovery Visibility: What Happens When Something Fails The Home Assistant community has documented a pattern that shows up repeatedly in homelab war stories: years of failures from SD-card corruption, overheating USB boot media, and missing off-host backups, often diagnosed only after the fact with LLM-assisted debugging. The instability was not random. It was predictable given the hardware choices, and it was survivable only for people who had off-host backups and a documented recovery path. Recovery visibility means knowing, before something fails, whether you could actually recover. That involves three things: **Backup completeness.** Are backups running on schedule? Are they completing without errors? Are they stored somewhere that survives the failure of the primary host? A backup job that silently fails every night looks identical to a successful one on most uptime dashboards. **Recovery documentation.** The LLM-assisted debugging story is instructive: when something breaks at 2am, you want a written runbook, not a mental model you have to reconstruct from memory. That runbook should cover the steps to restore from backup, the expected time to recovery, and any dependencies that need to come up in a specific order. **Boot media reliability.** SD cards and cheap USB drives are not suitable as primary boot media for anything you want to stay up. NVMe or SATA SSD boot with a watchdog for unresponsive states is a more durable baseline. If you are still on SD card boot for a critical service, that is a higher priority fix than adding more monitoring. Monitoring should surface backup job status alongside service health. A host that is up but has not completed a backup in five days deserves an alert just as much as a host that is down. ## Checklist - Add flow-level network monitoring (NetFlow/sFlow + a collector) if any services are externally exposed - Track Docker container restart counts and exit codes, not just whether the container is running - Monitor NAS disk SMART data and volume health on a schedule — do not wait for a failure to check - Set alerts for SSL certificate expiry at 30 days and 7 days out - Track domain renewal dates separately from your SSL monitoring - Monitor storage usage trends over time, not just current fill percentage - Verify backup jobs are completing successfully — treat a silent backup failure as an outage - Keep a written recovery runbook for your most critical services, updated every time you change the setup --- ## Homelab restore drills need permission and service-state checks, not just backup files Source: https://deploytovps.com/blog/guide/homelab-restore-drills-permissions-service-state Tags: backup-restore, proxmox, unraid, grafana, disaster-recovery Having a backup file is the easy part — the hard part is proving your restore path actually works when permissions, databases, and service startup order all have to line up. This guide walks through the failure patterns seen in Proxmox, Unraid, and TrueNAS recoveries and shows what a real restore drill looks like. Having a backup file is the easy part — the hard part is proving your restore path actually works when permissions, databases, and service startup order all have to line up. ## Why files alone are not enough A common homelab assumption is that a successful backup job equals a successful recovery. It does not. Recovery is a separate operation with its own failure modes, and most of them only surface during the restore itself. A Grafana operator recently demonstrated this in a public thread: they restored files from Proxmox Backup Server into an LXC Docker host and ended up with a read-only database. The backup ran cleanly. The files were all there. But ownership and mode information did not survive the manual restore path, so Grafana could not write to its own database directory after the transfer. The service started, appeared healthy at first glance, and then failed silently on the first write operation. This pattern — files present, service broken — is one of the most common failure modes in homelab recoveries. The other is service startup order, which trips up operators on Unraid, TrueNAS, and anything running containers behind a reverse proxy. ## The permission and ownership problem Every file on a Linux system carries an owner, a group, and a permission mode. When you copy files across hosts, containers, or filesystems using standard tools (`cp`, `rsync` without flags, or GUI file managers), those attributes are often stripped or reset to the user running the copy. The Proxmox Backup Server restore case above is a good example. PBS restores LXC containers faithfully, but if you manually extract files from a backup archive and drop them into a running container, you are responsible for preserving or re-applying ownership. For a Grafana instance running as UID 472, that means the `grafana-data` directory and all its subdirectories must be owned by 472:472 — or the process silently falls back to read-only mode. Practical steps for permission-aware restores: - Always use `rsync -aX` (archive mode with extended attributes) rather than plain `cp` when moving data between hosts. - After restoring into a container, run `ls -la` on the data directories before starting services and verify UIDs match what the service expects. - For Docker volumes, check the `Dockerfile` or image documentation for the expected UID/GID — it is often not `root`. - Store a small `permissions-manifest.txt` alongside each backup listing critical directory owners. A one-liner like `find /data -maxdepth 2 -printf '%U %G %m %p\n'` generates this in seconds. ## The service-state and startup-order problem Permissions are not the only thing that breaks a restore. Services often have startup dependencies that work fine in steady state but fall apart when the system comes back cold. An Unraid operator experimenting with ZFS pools removed a drive and rebooted. On the way back up, the webGUI, nginx, and PHP processes all failed to start cleanly because their startup dependencies were no longer satisfied in the expected order. Nothing was corrupted — the configuration was intact — but the service graph could not assemble itself without manual intervention. TrueNAS shows the same pattern from a different angle. Multiple users in install threads have found Jellyfin and Nextcloud stuck in a restart loop on clean installs until datasets, users, and ACL permissions were explicitly reset. The app-install wizard assumes a clean dataset tree, but if that tree was pre-populated from a backup or a previous install, the UIDs inside the dataset may conflict with what the new app instance expects. Service-state checks to include in every restore drill: - Run `systemctl status` or the platform equivalent for each service and confirm `active (running)`, not just `enabled`. - For reverse-proxy setups, verify the proxy can reach the upstream by hitting the internal port directly with `curl localhost:/health`. - For container stacks, check `docker compose ps` or the equivalent and look for containers stuck in `restarting` state — that almost always indicates a dependency or permission issue, not a configuration error. - If your platform uses a webGUI (Unraid, TrueNAS), confirm the webGUI itself is reachable before assuming downstream services are healthy. ## Building a repeatable restore drill A restore drill that only checks "are the files there" is not a drill — it is a file count. A real drill verifies that the system reaches a known-good operational state, end to end. Unraid had a public example of what a full recovery looks like: an operator had to rebuild boot media and restore config manually after a webGUI upgrade went wrong. The outcome was fine, but only because they knew the exact sequence of steps. That knowledge existed in their head, not in a runbook. The next person (or the same person, six months later) would have to reconstruct it under pressure. A repeatable restore drill includes: 1. **Document the expected end state** before you start. What services should be running? What ports? What health endpoints? 2. **Run the restore into an isolated environment** — a spare LXC container, a secondary VM, a test dataset. Never validate your restore path for the first time on production. 3. **Re-apply permissions explicitly** as a step in the runbook, not as an afterthought. 4. **Start services in dependency order** and pause between each layer to confirm the lower layer is healthy before starting the next. 5. **Test application-level writes**, not just process health. Write a test record to Grafana, upload a file to Nextcloud, index a new item in Jellyfin. Read it back. Confirm the write round-tripped. 6. **Time the drill.** Knowing that your Proxmox restore takes 40 minutes end-to-end, including permission re-application and smoke tests, is critical for realistic RTO planning. A tool like [ServerCompass](https://servercompass.app) helps here — it lets you see all your running services at a glance, so during a restore drill you have a clear dashboard to validate which services came back healthy and which are still missing, rather than SSHing into each host individually. ## Checklist - Store a `permissions-manifest.txt` alongside each backup listing owner, group, and mode for critical directories. - Use `rsync -aX` (not `cp`) when moving data between hosts or containers to preserve ownership and extended attributes. - After any file restore, run `ls -la` on data directories and verify UIDs match what the service expects before starting processes. - Confirm service startup in dependency order: database → application → reverse proxy; check each layer before starting the next. - For Docker or TrueNAS app installs, verify that dataset ACLs and user mappings are reset to match what the new container expects. - Run application-level write tests (not just process health checks) to confirm the restore is actually functional. - Practice the restore in an isolated environment before you need it in production — surprises during a real incident are expensive. - Track end-to-end restore time during drills so your RTO estimate is based on measured data, not guesswork. --- ## Immich newcomers need a library-ownership plan before importing cloud photo archives Source: https://deploytovps.com/blog/guide/immich-library-ownership-plan-before-importing-photos Tags: immich, photo-backup, nas, migration, docker Getting Immich running is only the first step — before you import years of Google Drive, OneDrive, or iCloud photos, you need a clear plan for which folders Immich owns, where the data actually lives on disk, and how the rest of your NAS fits into the picture. Skipping this step costs weeks of reorganization or, worse, accidental data loss. Getting Immich running is only the first step — before you import years of Google Drive, OneDrive, or iCloud photos, you need a clear plan for which folders Immich owns, where the data actually lives on disk, and how the rest of your NAS fits into the picture. Skipping this step costs weeks of reorganization or, worse, accidental data loss. ## The question nobody asks before hitting Import Most new Immich threads follow the same pattern. Someone gets the containers up, opens the web UI, and immediately wants to pull in a decade of vacation photos from Google Drive and OneDrive. The question they ask is usually "how do I import these folders?" but the question they actually need to answer first is "who owns these files after the import?" Immich has a specific model for library ownership. There are two kinds of libraries: the default upload library (Immich manages the files, moves them into its own folder structure under the upload path) and external libraries (Immich indexes files in a folder you control, but does not move or copy them). These two modes have very different consequences for your NAS layout, your backup strategy, and your ability to browse files outside of Immich. If you drop 50,000 vacation photos into an Immich-managed upload library, Immich reorganizes them by date into its own hierarchy. Your original folder names — "Bali 2019", "Wedding June 2021" — disappear from the filesystem. Immich preserves the album metadata inside its database, but if you ever need to access those files without Immich running (say, during a migration or a database restore), you are navigating a flat date-based tree instead of the structure you built over years. External libraries solve this, but they require you to pre-organize the data on your NAS before Immich ever touches it. ## Splitting Docker state from NAS library data On a NAS setup — a UGREEN, Synology, or any other box running Docker — the most common mistake is letting Immich's upload path land on the same SSD that runs the Docker volumes. That drive is optimized for random I/O and container state, not for storing 200,000 photos. The library data belongs on the spinning-disk array where you have capacity and redundancy. A clean split looks like this: - **Docker volumes** (database, Redis, model cache, thumbnails, encoded video): SSD, fast random I/O, relatively small. - **Library data** (originals): NAS array, large sequential reads, backed up to an offsite target. In your `docker-compose.yml`, this means the `UPLOAD_LOCATION` environment variable points to a mount on the array, not to a subfolder next to `postgres-data`. If you use external libraries, the external path also needs to be a bind-mount into the container pointing at the array. This distinction matters even more when you are migrating a family off Google Photos. One real case took nearly a year: the user ran immich-go to pull the Google Takeout archives, let Immich deduplicate and validate everything, ran parallel backups, and only deleted 200,000 cloud items after all of that was confirmed. During those months, both the Immich library and the original cloud archive existed simultaneously. That requires disk capacity planning before the first import, not after. ## Phone backup, mobile app, and the sync boundary Immich's mobile app does continuous backup from your phone. That is a separate data stream from the cloud archive import, and it lands in the managed upload library by default. Before you configure the app on every family member's device, decide: 1. Should phone backups land in the same upload library as imported cloud photos, or in a separate library per person? 2. If you have multiple family members, does each person get their own Immich user account, or is everything under one account? 3. What is the deduplication story if someone's OneDrive already contains their phone backup from the last three years? Immich deduplicates by hash, so if the same file exists in a Takeout export and a phone backup, it will not create a duplicate asset. But this only works if both files come through the same Immich instance. If you import the OneDrive archive first and then enable phone backup, the overlap will be caught. If you run separate Immich instances per person (a pattern some families use for privacy), deduplication does not cross instance boundaries. A tool like [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — useful when you have multiple Immich instances, a separate database container, and a backup agent all running on the same host and you need to confirm everything is healthy before kicking off a large import. ## Preserving folder structure with external libraries If folder names matter to you — and for vacation archives and family event collections they usually do — external libraries are the right choice for your imported cloud data. The workflow is: 1. Copy the Google Drive and OneDrive content to your NAS array, preserving the folder structure exactly as it came out of the cloud export. 2. Mount that path into the Immich container as a read-only bind mount. 3. Add it as an external library in Immich and run the initial scan. 4. Immich indexes the files and makes them searchable and browsable in the UI, but does not move or rename anything. The tradeoff is that Immich cannot write metadata back to the originals. If you edit a description or add people tags in Immich, that data lives in the Immich database, not in the file. This is fine as long as your Immich database is included in your backup plan — and it must be, because losing the database means losing all your tags, albums, and facial recognition data even if the original files are intact. For ongoing phone backups where you want Immich to manage the files directly, keep the managed upload library. For historical archives where folder structure matters, use external libraries. Many users end up running both. ## Checklist - Decide before importing: managed upload library (Immich owns file layout) vs. external library (you own the folders) - Put Docker volumes (database, thumbnails, model cache) on the SSD; put library originals on the NAS array - Plan your `UPLOAD_LOCATION` bind mount to point at the array, not the container SSD - If preserving folder names matters, copy cloud exports to the NAS first, then add as an external library - Map out deduplication boundaries — single Immich instance deduplicates across all imports; separate instances do not cross-deduplicate - Decide on per-user accounts before enabling the mobile app on family members' phones - Keep the Immich Postgres database in your backup plan — it holds albums, tags, and people data that cannot be reconstructed from the files alone - Run the cloud archive and the new Immich instance in parallel long enough to validate before deleting anything from Google Photos, OneDrive, or iCloud --- ## Internal DNS, IoT VLANs, and reverse proxies are a recurring self-hosting routing trap Source: https://deploytovps.com/blog/guide/internal-dns-iot-vlan-reverse-proxy-routing-trap Tags: reverse-proxy, dns, vlan, nginx-proxy-manager, homelab-networking When you segment your IoT devices onto their own VLAN, the clean setup you had on your trusted LAN stops working in ways that are hard to diagnose without understanding how DNS, hairpin NAT, and firewall rules interact. This guide walks through the routing trap and how to get out of it. When you segment your IoT devices onto their own VLAN, the clean setup you had on your trusted LAN stops working in ways that are hard to diagnose without understanding how DNS, hairpin NAT, and firewall rules interact. ## The Setup That Works on a Flat LAN The typical homelab routing stack looks like this: Docker services run on a NAS or server, Nginx Proxy Manager (NPM) sits in front of them as a reverse proxy, and your router — often a Unifi device — has CNAME records pointing friendly hostnames like `plex.home` or `jellyfin.home` at the NPM host's local IP. On the trusted LAN this works without friction. You type `plex.home` in a browser, your device queries the router's DNS, gets the local IP for NPM, NPM forwards the request to the Docker container, and Plex responds. This is a reasonable setup. The problem shows up the moment you start isolating devices. ## What Breaks When You Add an IoT VLAN The question that trips up most people is deceptively simple: if I move my Roku to an IoT VLAN, will `plex.home` still resolve, and will traffic actually reach NPM? There are two different failure modes here, and they are easy to confuse. **DNS resolution fails entirely.** Most routers only serve DNS to the VLAN they are configured to handle, or they serve all VLANs but only return records from a single internal zone. If your IoT VLAN's DHCP hands out a DNS server that does not know about your local CNAMEs, the hostname won't resolve at all. The Roku will either time out or fall back to trying to reach `plex.home` as if it were a public domain, which will fail. **DNS resolves, but traffic is blocked.** If the IoT VLAN does get a valid answer — either because you are running a DNS server like Pi-hole or AdGuard Home that serves all VLANs, or because the router's internal DNS is reachable — the IP it returns is still the local IP of NPM on the trusted LAN. Now the IoT device tries to open a TCP connection to that IP, and your firewall rules step in. By default, inter-VLAN traffic is blocked. The connection drops silently. A third issue is less common but worth knowing: **hairpin NAT confusion**. Some people expose their services through a public domain (e.g., `plex.yourdomain.com` pointing at your home IP via dynamic DNS), and they assume traffic from inside the house will "leave and come back" through that public IP. On many consumer routers it doesn't — hairpin NAT is inconsistent, and you can end up with traffic that routes differently depending on which VLAN the client is on. Relying on the public hostname for internal access is fragile. ## How to Fix It Properly The right approach has three parts: make DNS work across VLANs, open a narrow firewall rule for the specific traffic, and avoid depending on hairpin NAT. **Serve internal DNS to all VLANs.** Run a dedicated DNS resolver — Pi-hole, AdGuard Home, or even a simple Unbound instance — on a machine that is reachable from all your VLANs. Configure each VLAN's DHCP to hand out that resolver as the DNS server. In that resolver, add local A records (not CNAMEs to avoid chaining issues) pointing your service hostnames directly at NPM's IP. This way, a Roku on the IoT VLAN gets the same answer for `plex.home` as a laptop on the trusted LAN. **Add a targeted firewall rule.** In Unifi (or pfSense/OPNsense if you are running a proper firewall), add a rule that allows traffic from the IoT VLAN to the specific IP and port of NPM on the trusted LAN. Do not open the entire IoT VLAN to the entire trusted LAN — that defeats the purpose of segmentation. The rule should read: IoT VLAN → NPM host IP → port 80/443 → allow. Everything else stays blocked. **Point all internal clients at local hostnames, not public domains.** If you have a public CNAME for remote access, keep using it remotely, but make sure your internal DNS overrides it with the local IP. This is called DNS split-horizon or DNS override. It eliminates hairpin NAT as a variable entirely. The same pattern applies regardless of which service is behind the proxy — Immich, Jellyfin, Home Assistant. The proxy issues and SWAG/Let's Encrypt breakage that appear in adjacent homelab threads often trace back to the same root cause: the proxy is not reachable from the client's VLAN, or the DNS answer the client gets is wrong for the network path it has to take. ## Keeping Track of What Is Running Where One thing that makes debugging this harder than it should be is not knowing the exact IP and port of each service at the moment you need it. When you are trying to write a firewall rule, you need the actual IP NPM is listening on, not a rough memory of where you deployed it six months ago. ServerCompass lets you see all your running services at a glance — ports, container status, and which host they are on — which is useful exactly in this situation. When you are writing a "IoT VLAN → NPM → allowed" rule, being able to pull up the service list immediately saves a round-trip of SSH and `docker ps` commands. More broadly, maintaining a simple inventory of what is exposed through NPM versus what is accessed directly, and which VLANs each client type lives on, makes future changes much less risky. A Roku today, a smart thermostat tomorrow — each new IoT device needs the same DNS and firewall treatment. ## Checklist - Run a DNS resolver (Pi-hole, AdGuard Home, or Unbound) that is reachable from all VLANs, and configure each VLAN's DHCP to use it. - Use A records for local service hostnames in your internal DNS instead of CNAME chains that could break resolution order. - Add a specific firewall rule: IoT VLAN source → NPM host IP → ports 80 and 443 only — do not open broad inter-VLAN access. - Do not rely on public domain hairpin NAT for internal traffic; use split-horizon DNS to return the local IP for internal clients. - Verify DNS resolution from the IoT VLAN using `nslookup` or `dig` from a test device or a temporary container on that segment. - After adding the firewall rule, test from the IoT device directly — a Roku or smart TV browser, or a phone moved to the IoT SSID. - For each new IoT device type added to the VLAN, confirm it gets the correct DNS server via DHCP before assuming the existing rules cover it. - Keep a current inventory of which services are behind NPM and their host IPs so firewall rules stay accurate as your homelab evolves. --- ## NAS app installs keep failing where GUI catalogs meet Docker permissions, datasets, and media shares Source: https://deploytovps.com/blog/guide/nas-app-install-docker-permissions-datasets-media Tags: truenas, unraid, jellyfin, nextcloud, storage-permissions NAS GUI catalogs make app deployment look straightforward, but storage permissions, dataset ownership, and generated Docker configs regularly cause installs to fail silently or break mid-way. This guide walks through the real failure modes seen with TrueNAS Scale, Unraid, Jellyfin, Nextcloud, and Samba — and how to work around them. NAS app catalogs promise one-click installs, but behind every clean progress bar is a tangle of dataset permissions, Docker volume mounts, and UID/GID mappings that the UI never fully exposes. ## Why the GUI hides the hard parts GUI-driven app stores on TrueNAS Scale, Unraid, and similar platforms are built to abstract away container configuration. You pick an app, fill in a few fields, hit install — and the system generates a Docker Compose or Helm chart behind the scenes. That abstraction works well for simple stateless apps. It breaks down the moment an app needs to read or write to a dataset that was created with a different owner, or when the generated config pins a UID that doesn't match the one your media files were written with. The core problem is that the UI shows you success states, not error states. A Jellyfin install can appear to finish and even show a running container while the app itself is logging permission denied errors on every scan attempt. You only find out when you open the web UI and see an empty library, or when you check the container logs directly. ## The TrueNAS Scale / Nextcloud permission spiral A pattern that comes up repeatedly in TrueNAS threads: a user gets one app working — say, Jellyfin — only after a full teardown. That means deleting the app, removing the users and groups the app created, deleting the dataset, recreating the dataset with explicit ACL entries, and reinstalling from scratch. The working state is fragile. Then they try Nextcloud, and the same dataset-permission logic breaks again during image pull or app creation. This happens because TrueNAS Scale apps (whether via the official catalog or TrueCharts) may create their own internal users and groups with specific numeric IDs. If the dataset's ACL doesn't include those IDs — or if a previous failed install left behind a partial user entry — the new container gets a permission mismatch before it even runs its entrypoint. The fix is methodical: 1. Before reinstalling, verify which UID/GID the app expects. Most chart documentation lists these; TrueCharts entries usually note them in the "storage" section. 2. Delete any leftover users/groups from prior install attempts (`System > Users`, `System > Groups`). 3. Set the dataset ACL explicitly: owner user + group matching the app's expected IDs, with recursive apply. 4. Only then reinstall the app. Nextcloud has an additional wrinkle: it runs its own database (MariaDB or PostgreSQL) as a sidecar, and the database volume needs write access under a different UID than the PHP-FPM process. If you're seeing Nextcloud fail during "image/app creation" rather than at runtime, it's usually the database volume that's blocked, not the web root. ## Rebuilding on Unraid after a NAS failure Unraid's Docker layer is more transparent than TrueNAS's catalog system — you define containers manually or via Community Applications templates, and the volume mappings are visible in the UI. But starting from scratch after a dead Synology or failed array introduces its own set of problems. When rebuilding a stack that includes Immich, PaperlessNGX, and Plex or Jellyfin, the order of operations matters: - **Data recovery first.** Before launching any new container, mount the old drives read-only and copy your media and database files to the new array. Immich and PaperlessNGX both store metadata in Postgres; if you launch fresh containers before restoring those databases, you'll lose all your tags, albums, and document index. - **Set consistent UID/GID across the stack.** Pick one user (usually `nobody`/`users` on Unraid, UID 99 / GID 100) and apply it uniformly across all media-facing containers. Mixing UIDs between Jellyfin and Plex on the same share causes read errors when both try to update metadata. - **Don't share config volumes between apps.** Community Applications templates sometimes suggest reusing `/mnt/user/appdata/` paths. Give each app its own subdirectory with no overlap. For the Docker experimentation side of an Unraid rebuild, keeping experimental containers on a separate VLAN or at minimum a separate bridge network prevents them from interfering with production media services while you test. ## Samba: bare-metal daemon vs. container vs. UI The "which surface should I use to manage Samba" question comes up whenever shares start dropping connections. The short answer: bare-metal daemon if you need stable long-running shares; container only if portability matters more than reliability. Samba running as a system service (`smbd`/`nmbd`) has direct access to the kernel's VFS layer and handles reconnection and locking better than a containerized Samba instance bridging through a Docker network. Containerized Samba adds a translation layer for every file operation, and if the container's bridge network hiccups, all connected clients see a disconnect — even if the underlying files are fine. NAS GUIs that offer Samba management (TrueNAS's SMB service, Unraid's Shares UI) are thin wrappers over the same system daemon. Using them is fine, but when shares start dropping: - Check `smb.conf` for `keepalive` and `deadtime` settings — defaults are conservative. - Look at kernel logs for NFS/CIFS buffer errors, not just Samba logs. - If you're behind a firewall, confirm ports 445 and 139 aren't being rate-limited. A tool like [ServerCompass](https://servercompass.app) lets you see all your running services at a glance, including which containers and daemons are actually up — useful for distinguishing "Samba is down" from "the container hosting Samba is down" without SSH-ing into every machine. ## Checklist - Before reinstalling any NAS app, delete leftover users, groups, and datasets from prior failed attempts - Check the app's expected UID/GID in chart documentation before setting dataset ACLs - Apply ACLs recursively on the dataset and confirm ownership with `stat` or `ls -la` before launching the container - For Nextcloud on TrueNAS, treat the database sidecar volume as a separate permission target from the web root - On Unraid rebuilds, restore Postgres databases (Immich, PaperlessNGX) before starting fresh containers - Use a single consistent UID/GID across all media-facing containers on the same share - Prefer bare-metal Samba daemon over containerized for stable long-running shares - Use a service dashboard (e.g., ServerCompass) to distinguish container-level from daemon-level failures before digging into logs --- ## NAS app updates need rollback checks before Immich, Jellyfin, and jails fail quietly Source: https://deploytovps.com/blog/guide/nas-app-update-rollback-immich-jellyfin-truenas Tags: nas, immich, jellyfin, truenas, rollback Updating NAS app stacks without a rollback plan is how Immich uploads stop working, Jellyfin streams drop, and TrueNAS jails start dying silently every few days. Here is what to check before and after every update. Updating a NAS or home-server app stack feels low-stakes until something breaks at 10 PM and you realize the rollback path was never tested. ## How Updates Break Things Quietly The failure pattern across NAS platforms is consistent: an update ships, the service restarts, and for a few hours or days everything looks fine. Then Immich uploads hang, Jellyfin streams drop mid-playback, or a TrueNAS jail stops responding. The logs may not surface the root cause immediately, and by the time you notice, the update has already touched the network stack, kernel modules, or storage layer underneath your apps. A concrete example: a TrueNAS 26 beta update introduced packet drops on a custom Docker network. Immich uploads began failing silently and Jellyfin streams cut out. The fix was a rollback to the previous TrueNAS version, but that required a clean restore from a configuration backup — not just a package downgrade. Without that backup already in place, the rollback would have been far messier. A separate issue on TrueNAS involves jails that die every three to seven days. The jail itself gives no alert; only an external monitor catches the outage. This is a different failure mode — not caused by a single update, but by a slow memory leak or resource contention that compounds over time. Without something watching the jail from outside, the service just stops quietly. ## The Synology/Jellyfin Transcoding Trap Jellyfin on Synology has its own failure pattern tied to transcoding. After a Jellyfin update, playback on Roku (and similar clients that request transcoded streams) can break while direct-play clients work fine. The likely cause is a transcoding dependency — `ffmpeg` path, hardware acceleration settings, or a codec that changed between versions. The frustrating part is that the failure only surfaces on specific client/format combinations. A quick test from a browser might pass while the Roku app fails on every stream. This makes it easy to assume the update is clean when it is not. Before updating Jellyfin on a Synology NAS: note your current transcoding settings and test a forced-transcode stream from your primary client after the update, not just a direct-play stream. ## Unraid, ZFS, and the Kernel Panic Recovery Unraid's flexibility is also its risk surface. One recovery path that worked: after a kernel panic triggered by a ZFS/driver conflict, the user booted from the Unraid USB drive, restored from a backup, then migrated the storage pool from ZFS to Btrfs to avoid the driver issue entirely. The fix worked, but it required having a known-good USB boot image and an offsite or secondary backup of the array data. The lesson is not that ZFS is wrong for Unraid — it is that storage-layer changes (format migrations, kernel upgrades, driver updates) carry higher blast radius than app-layer updates. If the kernel panics, you are not just rolling back an app; you are recovering an OS. Tools like [ServerCompass](https://servercompass.app) let you see all your running services at a glance, which helps you catch which services stopped responding after a storage or kernel event without SSHing into each host manually. ## Before and After Every Update The pattern across all these platforms points to the same missing step: a pre-update snapshot and a post-update verification pass. Most NAS platforms support configuration exports and VM/jail snapshots, but they are opt-in and easy to skip. For TrueNAS, the configuration export (under System > General) gives you a restore point that survives a version rollback. For Unraid, the USB boot image is the equivalent — keep a known-good copy off the NAS itself. For Docker-based stacks, a `docker compose` file checked into version control is your rollback: pin image versions, do not use `latest`. Post-update verification should cover the specific failure modes for each service: - Immich: trigger an upload from the mobile app and confirm it appears in the library. - Jellyfin: force a transcode (not direct play) from your primary client. - TrueNAS jails: check jail status after 24 hours, not just immediately after restart. - Unraid: confirm array parity is not rebuilding unexpectedly after a reboot. An external monitor — a simple uptime check against each service's health endpoint — is the minimum safety net for catching silent failures that happen hours after the update completes. ## Checklist - Export a TrueNAS configuration backup before any system or beta update, not after. - Keep a dated, off-NAS copy of the Unraid USB boot image before kernel or storage-layer changes. - Pin Docker image versions in your compose files; never rely on `latest` in production stacks. - After updating Jellyfin, test a forced-transcode stream from your primary client (Roku, Apple TV, etc.), not just a browser direct-play. - After updating Immich, trigger a real upload from the mobile app and confirm it reaches the library. - Set an external uptime monitor on each jail or container health endpoint — do not rely on the NAS UI alone to surface silent failures. - If a TrueNAS jail dies on a recurring cycle (every few days), treat it as a resource leak and check memory/CPU limits before the next restart. - After any storage-layer migration (ZFS to Btrfs, pool format change), verify parity and run a short SMART test before declaring the system healthy. --- ## NAS rollouts need one plan for media, backups, and future self-hosted apps Source: https://deploytovps.com/blog/guide/nas-rollout-media-backups-future-apps-plan Tags: nas, jellyfin, backup, storage-layout, beginner-homelab Most beginner NAS builds start with one goal — media streaming or laptop backups — and end up patched together when the second and third use cases arrive. Getting the storage model right before you add drives saves you from rebuilding everything six months later. Most beginner NAS builds start with one goal — media streaming or laptop backups — and end up patched together when the second and third use cases arrive. Getting the storage model right before you add drives saves you from rebuilding everything six months later. ## The real scope of a home NAS A pattern shows up repeatedly in self-hosted communities: someone posts asking for a future-proof NAS under a fixed budget, and the actual wish list turns out to be five or six things at once. They want the box to download media directly, stream to several users via Plex or Jellyfin, back up every laptop in the house, serve as family cloud storage (a Nextcloud replacement for Google Drive), and eventually host Bitwarden or a mail server. That is not a simple file server — that is a small homelab, and it needs to be planned as one. The problem is not ambition. The problem is skipping the storage layout decision until after the hardware is already running. A Ugreen NAS owner with two raw HDDs and no backup configuration is a good example: new files copied to one drive stop appearing in Jellyfin because the library path points somewhere else, and there is no safety net if a drive fails. Raw drives with no RAID and no defined mount structure leave you in a fragile state the moment you start adding services. ## Storage layout decisions you have to make before you add services Before you configure a single container, settle three things: **1. RAID or not, and which level.** RAID 1 (mirroring) protects against a single drive failure and is the minimum for any data you care about. RAID 5 or 6 gives you more usable space with parity protection across three or more drives. Neither RAID level is a backup — it only protects against hardware failure, not accidental deletion or ransomware. If you only have two drives right now, set them up as RAID 1 and plan to expand later rather than running them as independent volumes. **2. Separate volumes for separate concerns.** Media libraries and backups have very different access patterns and different retention policies. Put them on separate logical volumes or at minimum separate top-level directories with clear ownership. When you add Nextcloud or Bitwarden later, they should not share a directory tree with your Jellyfin library. Mixing everything into one flat folder makes it hard to set permissions, quotas, or snapshot policies per workload. **3. Mount points that survive reboots.** A recurring Jellyfin complaint is that a storage volume mounts at a different path after a reboot or NAS firmware update, and the library scan finds nothing. Use stable mount points — either fixed paths in `/etc/fstab` or named Docker volumes — and verify them after every system update. Do not rely on auto-mount paths that include device identifiers which can shift. ## Backup tiers that actually work at home The 3-2-1 rule (three copies, two media, one offsite) is the right target, but home setups usually get there in stages: - **Tier 1 — on-NAS snapshots.** ZFS and Btrfs both support copy-on-write snapshots that let you roll back accidental deletions. If your NAS OS supports it, enable automated snapshots on your data volumes. This costs almost no extra space for incremental snapshots. - **Tier 2 — laptop backups to NAS.** Time Machine (macOS) and Windows Backup work well with a dedicated NAS share. Keep this share separate from your media library so backup retention policies do not interfere with each other. - **Tier 3 — offsite.** Rclone to Backblaze B2 or an encrypted rsync to a remote VPS covers the offsite requirement. Even a small cold copy of your most critical data (documents, photos, passwords) is better than nothing. Media files are usually the least critical to back up offsite since they can be re-downloaded. Media library backup is a separate question. The media files themselves are often large and re-downloadable, so many people back up only the Jellyfin metadata and configuration rather than the full library. That is a reasonable call as long as you document it explicitly. ## Sizing your NAS for future self-hosted apps Jellyfin, Plex, and Nextcloud are storage-heavy. Bitwarden (Vaultwarden), a mail server, and similar apps are not — they need CPU and RAM more than raw disk space. This matters for hardware decisions. If you are choosing between a dedicated NAS device and a mini-PC with attached drives, the NAS-plus-mini-PC split is worth considering for a homelab that plans to run non-media services. The NAS handles bulk storage and backup. The mini-PC runs containerized apps and mounts NAS shares over the network. This keeps the failure domains separate: a NAS firmware update does not take down your password manager, and vice versa. For a single-box setup, make sure the CPU has enough headroom for hardware transcoding in Jellyfin (Intel Quick Sync or an NVIDIA GPU if you are transcoding for multiple users at once) while still running background processes for Nextcloud sync and backup jobs. A tool like [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — CPU, memory, and disk usage per container — which makes it much easier to spot when a Jellyfin transcode job is starving your Nextcloud sync or when a backup task is saturating your drives. ## Checklist - Decide on RAID level before adding any drives; two drives minimum means RAID 1, not independent volumes - Define separate mount points or Docker volumes for media, backups, and future apps — never mix them in one flat directory - Verify that mount paths survive a reboot by testing after your first firmware or OS update - Set up on-NAS snapshots (ZFS or Btrfs) as your first line of defense against accidental deletion - Configure laptop backup shares separately from media shares with independent retention policies - Plan an offsite backup for critical data (documents, photos, config files) even if you skip offsite for raw media - If you plan to run non-storage apps (Bitwarden, mail, monitoring), consider whether a separate mini-PC or container host makes more sense than adding services directly to the NAS - Use a dashboard like ServerCompass to monitor resource usage across services so you catch contention before it becomes an outage --- ## Proxmox Backup Server single-disk installs need datastore partitioning guardrails Source: https://deploytovps.com/blog/guide/proxmox-backup-server-single-disk-datastore-partitioning Tags: proxmox, pbs, backup, storage, restore Installing Proxmox Backup Server onto a single large disk without planning your partition layout first is a reliable way to strand yourself with a system drive consuming the whole device and no clean path to a backup datastore. This guide walks through the failure mode and how to avoid it. Installing Proxmox Backup Server onto a single large disk without planning your partition layout first is a reliable way to strand yourself with a system drive consuming the whole device and no clean path to a backup datastore. ## How people walk into this trap A common scenario: you decommission an older Proxmox VE host and decide to repurpose it as a dedicated PBS node. The machine has a single 7 TB hardware RAID device — plenty of space. You boot the PBS installer, accept the defaults, and the installer happily takes the whole disk for the OS. Installation finishes cleanly. Then you open the datastore creation dialog and realize the installer has left no usable free space. LVM has claimed the volume group, the partition table has no unallocated room, and every tool you reach for — `fdisk`, `GParted`, the GUI wipe button — either refuses or gives you a clean device that is now missing its OS. The fundamental issue is that the PBS installer, like the PVE installer, targets a single device and allocates it fully by default. On a machine with multiple disks this is fine — install to a small SSD, leave the spinning drives for data. On a machine with one large device, the installer gives you no obvious prompt to reserve space for a datastore partition. ## What the installer actually creates When PBS installs to a disk it lays down: - A small EFI or BIOS boot partition - A swap partition - A root filesystem partition (typically ext4) - An LVM volume group that fills the remainder The LVM volume group is the trap. PBS is aware of LVM and will happily use a logical volume or a raw partition as a datastore, but the GUI wipe operation that prepares a datastore disk wipes the *entire device*, including the partition that holds `/`. You cannot wipe your own root out from under yourself — the button greys out or errors — and you should not try. Attempting to resize the LVM volume group from a running system to free space for a new partition requires shrinking the root logical volume while it is mounted, which is not supported for ext4 without a live environment. ## How to partition correctly before you install The fix is to plan the layout *before* the installer runs, not after. **Option 1 — Use a separate disk for the datastore.** If you can add any secondary drive — even a modest SSD — install the OS there and dedicate the 7 TB device entirely to PBS datastore use. This is the cleanest architecture and matches how PBS is designed to be deployed. **Option 2 — Pre-partition the target disk manually.** Boot a live environment (Debian live ISO works), use `parted` or `fdisk` to create two partitions: one sized for the OS (64–128 GB is comfortable for PBS), one for the datastore. Then boot the PBS installer, point it at the first partition (you may need to select "advanced" options in the installer), and after install create the datastore on the second partition. Note that some PBS installer versions handle this poorly; test in a VM first if the hardware allows it. **Option 3 — Reinstall with a custom LVM layout.** The PBS installer's advanced mode lets you set the size of the root logical volume. Set it to 64 GB, leave the remaining space in the volume group unallocated, and after installation create a new logical volume for the datastore. PBS can use an LV directly as a datastore if you format it with the right filesystem — ZFS or ext4 both work, but ZFS is preferred for its data integrity features. **What does not work reliably:** - Resizing the root LV online to free space - Using `GParted` from within the running PBS system to shrink its own partition - The GUI "Wipe" button on a disk that holds the running OS ## Recovery if you are already stuck If you have already installed PBS and the disk is fully allocated, your options narrow: 1. **Boot a Debian/Ubuntu live ISO** from USB. From there you can use `lvresize --resizefs -L 64G /dev/pbs-data/root` to shrink the root LV, then `lvextend` or `pvcreate`/`vgextend` to carve out space for a second LV. This is safe if done carefully but requires shrinking an unmounted filesystem — boot the live ISO, do *not* mount the root partition read-write before resizing. 2. **Reinstall and start over.** If the PBS node has not yet stored any real backups, reinstalling with a corrected partition layout costs almost nothing. Document your PBS realm and user configuration first (`/etc/proxmox-backup/` holds most of it), back up `/etc/network/interfaces` and your datastore paths, then wipe and reinstall. 3. **Add an external datastore.** If adding a second disk is possible, do that and leave the single-disk layout as-is for the OS. A USB 3.0 or eSATA drive is not ideal for production backups but works for a lab or low-frequency off-site target. For ongoing visibility into your backup jobs, running services, and disk usage across nodes, a tool like [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — useful when you are managing PBS alongside PVE and want a quick health check without SSH-ing into each box. ## Checklist - **Plan partition layout before installer runs** — decide OS partition size and datastore partition size in advance - **Prefer two physical disks** — one for OS, one dedicated to PBS datastore - **Use the installer's advanced mode** to set a bounded root LV size if only one disk is available - **Leave LVM space unallocated** during install so you can create a datastore LV post-install without resizing - **Never rely on the GUI Wipe button** to reclaim space on the disk the OS is running from - **Boot a live ISO for recovery** — shrink the root filesystem only when it is unmounted - **Document PBS config files** (`/etc/proxmox-backup/`) before any reinstall - **Test the layout in a VM first** if the target hardware is inconvenient to rebuild --- ## Proxmox operators need network-path and out-of-band recovery checks before backups or VMs fail at the worst moment Source: https://deploytovps.com/blog/guide/proxmox-network-path-recovery-checks-preflight Tags: proxmox, pbs, networking, backup, recovery Running Proxmox with multiple bridges and bonded links creates subtle reliability gaps that only surface when a backup fails or a host refuses to boot. A short preflight checklist — covering PBS traffic paths, LACP validation, and out-of-band recovery — can prevent the scramble of moving a monitor and keyboard to a dead node at 2 AM. Running Proxmox in a home lab or small production environment is not complicated — until it is. Three recurring failure patterns show up again and again in operator communities: backup traffic quietly using the wrong network bridge, bonded 10GbE links that are mysteriously capped at single-link speeds, and a host that refuses to boot with no remote way in. None of these are exotic edge cases. All of them are preventable with a short preflight check before you rely on the setup. ## Making Sure PBS Traffic Uses the Right Bridge A common Proxmox node layout has separate bridges for management, VM traffic, and backup storage. The intent is traffic isolation — you do not want backup jobs saturating the same pipe that VMs use to talk to each other. The problem is that Proxmox Backup Server does not automatically route its traffic through the bridge you designated for backups. It follows the host routing table, which may point straight out of the management interface. To verify where PBS traffic actually lands, run a backup job while watching interface counters: ```bash watch -n 1 'cat /proc/net/dev | grep -E "vmbr|bond|eth|enp"' ``` If you see bytes accumulating on `vmbr0` (your management bridge) instead of `vmbr2` (your intended backup bridge), your routing table is not set up correctly. Fix it by adding a static route on the host that sends traffic destined for your PBS server's subnet through the correct gateway: ```bash ip route add 192.168.20.0/24 via 192.168.20.1 dev vmbr2 ``` Make the route persistent by adding it to `/etc/network/interfaces` under the backup bridge stanza using a `post-up` directive. Without this, every reboot silently reverts to the wrong path and your "isolated backup network" is an illusion. Also confirm the PBS datastore address resolves to an IP on the backup subnet — not a hostname that might resolve to a management IP. A simple `traceroute ` from the Proxmox node before the first backup job saves a lot of confusion later. ## Validating Bonded 10GbE Links with LACP Dual 10GbE links bonded with LACP (802.3ad) should deliver up to 20 Gbps of aggregate bandwidth. In practice, a single TCP flow is still pinned to one physical link — that is how LACP hashing works. But if you are stuck at exactly 10 Gbps even across multiple simultaneous flows, the bond is probably not negotiating correctly. Start at the switch. LACP requires the switch ports to have LACP enabled and be in the same port-channel group. If the switch is running static link aggregation (mode "on" rather than "active" or "passive"), the Linux bond may fall back to active-backup behavior without advertising it clearly. On the Proxmox host: ```bash cat /proc/net/bonding/bond0 ``` Look for `MII Status: up` on both slave interfaces and confirm `802.3ad info` shows a valid `Actor Key` and `Partner Key`. If one slave shows `MII Status: down` or the partner key is zero, the LACP negotiation failed and only one link is carrying traffic. Also check the bond's `xmit_hash_policy`. The default `layer2` hashing uses only MAC addresses, which tends to send all traffic between the same two hosts down a single link. Switching to `layer3+4` uses IP and port, distributing flows more evenly: ```bash bondmode=$(cat /sys/class/net/bond0/bonding/xmit_hash_policy) echo $bondmode ``` If it reads `layer2`, update `/etc/network/interfaces` to add `bond-xmit-hash-policy layer3+4` under the bond definition. A tool like [ServerCompass](https://servercompass.app) lets you see all your running services at a glance alongside real-time throughput per interface, which makes it easy to spot a bond that is only using one of its two legs without running ad hoc commands every time. ## Building an Out-of-Band Recovery Path A Proxmox host that refuses to boot is a different class of problem. If the only way to recover is to physically move a monitor and keyboard to the machine, you have no out-of-band access — and that gap is felt hardest at the worst possible moment. For hardware that supports it, configure IPMI or iDRAC before you need it. Most server-grade motherboards and many prosumer boards (ASRock Rack, Supermicro) ship with a dedicated IPMI NIC. The setup takes about 15 minutes: 1. Assign a static IP to the IPMI interface from the BIOS/BMC setup screen. 2. Put the IPMI NIC on your management network (not the backup or VM bridge). 3. Test the web console and virtual media from another machine before the host is in production. 4. If your switch supports it, put the IPMI port on a dedicated VLAN so BMC traffic stays separate from everything else. For consumer hardware without IPMI, a cheap KVM-over-IP device (PiKVM, JetKVM, or similar) attached to the HDMI and USB ports gives you remote keyboard and screen access. This is not as clean as a BMC, but it covers the core failure mode: a host that is stuck at a BIOS prompt or kernel panic screen. If the host refuses to boot entirely, the next recovery step is usually a USB drive with a Proxmox rescue image or a live Debian environment. Keep one prepared and labelled, stored somewhere accessible near the hardware. Document which USB port to use — some boards are particular about which ports are read early in POST. ## Pre-Deployment Preflight Habits All three of these failure modes share a root cause: assumptions that were never verified. The bridge topology looked correct in the Proxmox web UI. The bond showed as "up" in `ip link`. The host had always booted before. The fix is not more monitoring after the fact — it is a short verification pass before you trust the system with real workloads. Run a test backup job and confirm which interface carries the traffic. Generate enough cross-host traffic to see whether both bond legs are active. Trigger a BMC console session to confirm it works. These take under 30 minutes total and they surface the problems before a Saturday-night failure forces you to move hardware around in the dark. Keeping notes on what you verified and when is also worth the effort. A plain text file or a wiki page per node — covering the bridge layout, bond mode, PBS storage address, and IPMI IP — means the next operator (or future you) does not have to reconstruct this from scratch. ## Checklist - Verify PBS backup traffic is actually using the intended backup bridge by watching interface counters during a live backup job - Add a persistent static route on the Proxmox host so backup-subnet traffic goes through the correct gateway, not the management interface - Confirm the PBS datastore hostname resolves to an IP on the backup subnet, not the management IP - Check `/proc/net/bonding/bond0` to confirm both LACP slave links are up and the partner key is non-zero - Set `bond-xmit-hash-policy layer3+4` in `/etc/network/interfaces` to distribute multi-flow traffic across both links - Configure and test IPMI/BMC access before the host goes into production; verify the web console and virtual media from a separate machine - For consumer hardware without IPMI, attach a KVM-over-IP device and confirm remote access before you need it - Keep a bootable USB rescue drive prepared and stored near each host, and document which USB port the board reads during POST --- ## Proxmox upgrades and laptop-server moves need IO and thermal preflights before production apps Source: https://deploytovps.com/blog/guide/proxmox-upgrade-laptop-server-io-thermal-preflight Tags: proxmox, io-delay, hardware-reuse, homelab, upgrade-preflight Upgrading Proxmox from version 8 to 9 or migrating production services to a workstation laptop both carry hidden failure modes that only surface under load. Running IO and thermal preflights before you commit keeps your services from going dark at the worst possible moment. Upgrading Proxmox from version 8 to 9 or migrating production services to a workstation laptop both carry hidden failure modes that only surface under load — and the community threads from the past few weeks make that painfully clear. ## What happens when IO delay climbs to 99% One common pattern after a Proxmox 8-to-9 upgrade is a web UI that feels sluggish at first and then becomes nearly unresponsive. The culprit, in multiple recent cases, is IO delay spiking toward 99%. This is not a Proxmox bug per se — it is the upgrade exposing a pre-existing storage bottleneck that the previous kernel or scheduler happened to mask. IO delay at that level means the CPU is almost always waiting on disk rather than doing work. VMs freeze, guests become unresponsive, and the Proxmox UI itself times out because the node cannot service requests fast enough. The upgrade did not break the disk — it changed how the system schedules IO and suddenly the disk's real throughput limit became visible. The fix depends on the root cause: a degraded ZFS pool, a slow spindle masquerading as the boot device, a misconfigured cache tier, or simply a drive that was already close to failure. None of those are diagnosable after the fact while services are down. They are all diagnosable beforehand with a ten-minute preflight. ## The laptop-server temptation and why thermal discipline matters A separate but related pattern is the homelab operator who wants to consolidate. The scenario: you have a small, low-power N150 box running Immich, Nextcloud, Gitea, Uptime Kuma, Hermes, and a full arr stack. You also have a workstation laptop — more cores, more RAM, more storage — sitting nearby. Moving everything to the laptop sounds like a straightforward win. The problems that get skipped in that reasoning are heat, noise, and mobility. Workstation laptops are built for burst workloads, not sustained IO under continuous service load. The fan curve that works for a one-hour render does not work for a process that never idles. Thermal throttling under sustained load is common, and when it kicks in it degrades performance in a way that looks like an application problem rather than a hardware one. Noise is a real ops concern if the machine is in a living space. A fan that ramps to 4000 RPM under load at 2 AM is not a theoretical problem. The relocation constraint is the sleeper issue. If you are moving continents and the laptop is also your daily driver, you are one checked-bag policy change away from losing your server. Services running on hardware that moves with you are not self-hosted — they are hostage to travel. ## Running the preflight before you commit The right sequence for either scenario — major Proxmox upgrade or hardware migration — is to validate the hardware under production-like load before any live services depend on it. For IO, `fio` is the standard tool. A simple sequential read/write test followed by a random 4K test tells you the real throughput and latency floor. On Proxmox, `iostat -x 1` during a synthetic load shows you IO wait in real time. If IO wait climbs above 20–30% under modest synthetic load, it will hit much higher under real workload. Fix the storage first: check ZFS pool health with `zpool status`, replace degraded drives, and consider whether a cache device is appropriate. For thermal, stress the CPU and disk together with `stress-ng` or equivalent for at least 30 minutes while watching `sensors` output. A laptop that throttles at 15 minutes is not a server. Check the throttle log after the run — on Linux, `dmesg | grep -i throttl` surfaces thermal events that the realtime output may have scrolled past. For the Proxmox upgrade path specifically, take a snapshot or backup of every VM before running `pve8to9`. The upgrade itself is usually clean, but storage-related surprises after reboot are much easier to recover from when you have a known-good restore point. Multiple threads in the community right now mention restore anxiety as a reason people hesitate to upgrade — the anxiety is reasonable, but the answer is tested backups, not skipping upgrades. If you are running multiple services across a homelab — whether on Proxmox VMs, a laptop, or a mix — keeping a live view of what is actually running saves time during diagnosis. [ServerCompass](https://servercompass.app) lets you see all your running services at a glance, so when IO delay or thermal throttling causes something to go silent, you know immediately which service dropped rather than discovering it when a user complains. ## Storage layout and passthrough considerations Beyond raw IO performance, the storage layout choices made at install time become load-bearing after a major upgrade or migration. ZFS on a single spinner without a separate SLOG or L2ARC will behave very differently under mixed random IO than it did under light homelab traffic. The upgrade to Proxmox 9 changes nothing about this — but it changes the kernel and scheduler, which changes how that bottleneck manifests. PCIe passthrough to VMs also introduces a dependency that survives upgrades badly. If a GPU or NIC is passed through to a guest and the host kernel update changes the IOMMU groupings, the VM will not start after reboot. This is documented but catches people off guard because it works fine on version 8 and then silently breaks after the upgrade. Check your IOMMU groups with `find /sys/kernel/iommu_groups/ -type l` before and after the upgrade to confirm nothing shifted. For the laptop-migration scenario, the equivalent concern is USB-attached storage. Laptop servers often rely on USB drives for bulk storage. USB 3 throughput is adequate for sequential reads but suffers under sustained random write load. Combine that with thermal throttling and you have two compounding performance limiters that are invisible until the system is under real load. ## Checklist - Run `fio` sequential and random 4K tests on all storage devices before upgrading Proxmox or migrating services; flag any device with IO wait consistently above 20% - Check ZFS pool health with `zpool status` and resolve any degraded or faulted vdevs before the upgrade - Take snapshots or full backups of every VM before running `pve8to9`; verify at least one restore works before proceeding - Run a 30-minute thermal stress test on any laptop intended as a server; check `dmesg | grep -i throttl` after the run - Confirm IOMMU group assignments with `find /sys/kernel/iommu_groups/ -type l` before and after a major kernel-bump upgrade if you use PCIe passthrough - Do not run production services on hardware that also serves as a travel machine; the relocation risk is structural, not fixable with software - Use a service monitor like [ServerCompass](https://servercompass.app) so thermal or IO events that kill a service are visible immediately rather than discovered through user complaints - Budget for a dedicated low-power host (N100/N150 class) if laptop heat and noise are constraints — the power draw difference over a year often pays for the hardware --- ## Release-Linked Health Checks: Closing the Post-Deploy Blind Spot on a Self-Hosted VPS Source: https://deploytovps.com/blog/guide/release-linked-health-checks-vps Tags: deployment, health-check, monitoring, release-verification, self-host, vps Your CI says the deploy succeeded. Your uptime check is green. And production is still broken. The fix is wiring release events into the verification step so 'deployed' and 'deployed-and-verified' are different states — here is the four-step pattern. CI says the deploy succeeded. The new container is running. Pingdom is green. Three hours later, a customer emails to say checkout has been broken since lunch. You look at your dashboards and discover the new release silently regressed a code path that your generic health check does not cover. This is the post-deploy blind spot, and it is the most common monitoring failure in self-hosted VPS deployments. The deploy step and the verification step live in different worlds. Your CI knows about releases. Your monitoring knows about uptime. Nothing knows about both at once. So a green deploy plus a green uptime check feels like a successful release, even when it is not. The fix is not more monitoring. The fix is wiring the health check into the release event itself, so that "deploy" and "deploy verified" are different states, and you cannot leave one without entering the other. This guide walks through the gap, why generic uptime checks miss it, and the concrete steps to close it on a self-hosted VPS. ## Why generic uptime checks lie to you after a deploy A standard uptime check pings `/` or `/health` every 30 seconds. It returns 200, the dot stays green, the dashboard looks healthy. It tells you almost nothing useful about whether your release actually works. Two specific failure modes get past it: - **Surface checks pass while business logic regresses.** A `/health` endpoint that returns `{"ok":true}` will keep returning that even after you ship a release that breaks the checkout flow, the email worker, or the payment webhook. The health endpoint only knows about itself. - **The release-event lag is invisible.** Generic uptime checks have no idea a deploy happened. They will report ten minutes of greenness on the *old* container and ten minutes of greenness on the *new* container, with no marker between them. When you go look at the dashboard later, you cannot tell which container the green dots belong to. The combination is what produces the "the deploy worked, but production is broken" scenario. The data is technically correct. The mental model the data supports is wrong. ## What a release-linked health check actually checks The shift is from "is the server up" to "did this release work." That requires a different set of probes, run at a different time, against a different surface. A release-verification probe should hit the surfaces that matter for *your* product, not generic ones: - The critical business flows — `POST /api/orders` with a synthetic order, `POST /api/login` with a test user, `GET /api/dashboard` with an authenticated session. - The dependencies — does the database respond, does the queue accept jobs, does the email worker dequeue and process a synthetic message. - The contract — does the new release still serve the API responses the frontend expects, or has a field quietly disappeared. These probes are not appropriate for every-30-seconds uptime monitoring. They are too expensive and they create side effects. They are appropriate for *release verification* — one-shot, deep, triggered by the deploy event itself. ## The four-step pattern for tying deploys to checks This is the pattern that works on a typical self-hosted VPS, regardless of whether you are using Docker Compose, Coolify, Dokploy, or a plain `systemd` setup. ### 1. Emit a release event when the deploy completes The deploy step has to publish, at minimum, three facts: which release, when, against which environment. Send these somewhere your verification job can read. Options that are cheap to operate on a single VPS: - A POST to your own `/api/releases` endpoint that writes a row to a `releases` table. - An entry in a tiny SQLite file at `/var/lib/releases/log.db`. - A line appended to `/var/log/releases.log` in a known format. The exact transport does not matter. What matters is that *something other than the deploy script* now knows a release happened, and at what timestamp. ### 2. Run a verification job immediately, blocking the deploy on it The verification job runs against the new release, hits the business-critical probes, and returns a single pass/fail. ```bash #!/usr/bin/env bash set -e RELEASE_ID="$1" curl -fsS https://yourapp.example.com/health/release || exit 1 curl -fsS -X POST https://yourapp.example.com/api/orders/synthetic \ -H "Authorization: Bearer $SYNTHETIC_TOKEN" || exit 2 curl -fsS https://yourapp.example.com/api/dashboard \ -H "Authorization: Bearer $SYNTHETIC_USER" || exit 3 echo "release $RELEASE_ID verified" ``` The deploy script does not exit successfully until this job passes. If it fails, you have a known state — the deploy is partially complete, the verification did not pass, and someone needs to make a decision. That is a much better state than "the deploy script said success and nobody looked again." ### 3. Tag every metric and alert with the release ID This is the change that makes the data interpretable later. Every metric your app emits — latencies, error counts, queue depth, whatever — needs a `release_id` label or tag. Every alert needs to include the current release ID in its payload. When something breaks two hours after a deploy, the question "which release introduced this" becomes trivially answerable instead of an archaeology project. When you bisect, you bisect by release ID, not by timestamp. If you are using Prometheus or a Prometheus-compatible TSDB, this is a one-line label change in your exporter. If you are using something simpler (uptime-kuma, healthchecks.io), the equivalent is to include the release ID in the check name or description. ### 4. Couple the alert thresholds to "minutes since last release" The error rate that should page you at 3am on a quiet Tuesday is different from the error rate that should page you in the first five minutes after a release. New releases get scrutiny windows. Your alert rules should know this. The simplest version: an alert rule that fires on `error_rate > 1%` for ten minutes, *or* `error_rate > 0.2%` for two minutes if `minutes_since_release < 15`. The tight window catches release regressions early. The loose window catches gradual problems without page fatigue. This is the single change that converts "we discover release breakage from customer emails" into "we discover release breakage before the deploy script even returns." ## A minimal implementation on a single VPS If you are running one VPS and you want the smallest version of this that produces value, the entire stack is: - A `releases` table or log file written to by the deploy script. - A `verify.sh` script that hits three critical probes, run synchronously by the deploy. - Uptime-Kuma (or a similar tool) with one push-style check per business flow, with the release ID encoded in the check name. - A Grafana annotation, or even a manually-curated text log, marking when each release happened so dashboards have something to bisect against. That is roughly an afternoon of work. It does not require buying anything. It does not require migrating to Kubernetes. It does not require a separate observability vendor. The reason it matters disproportionately is that the alternative — discovering release breakage from a customer email — is the single most expensive failure mode in self-hosted operations. Not because the bug is hard to fix once you know about it, but because the time between "release went out" and "we noticed it was broken" is unbounded. Closing that loop, even crudely, changes the failure mode from "find out hours later" to "find out before the deploy returns." ## When to invest more The four-step pattern above is the floor. There are several places it gets meaningfully better, in roughly this order: - **Canary or staged rollout** — instead of flipping all traffic to the new release, route a small percentage and let the verification check run against real traffic for a few minutes before fully promoting. This requires a reverse proxy that supports weighted routing (Traefik, nginx with `split_clients`, Caddy with `handle_path`). - **Automatic rollback on verification failure** — keep the previous container or release artifact warm, and if the verification job fails, point the proxy back at it. This is harder than it sounds because of stateful migrations, but for any release that does not touch the database schema, it is achievable. - **SLO-aware deploy gating** — block the deploy entirely if the current release is already burning its error budget. This requires you to actually have SLOs, which is itself a healthy forcing function. You do not need any of those on day one. You need to stop the silent-success failure mode first. ## What to take from this The reason post-deploy blind spots persist is not that the right tools do not exist. The tools exist. They are decoupled because the deploy world and the monitoring world were built by different teams, for different audiences, on different cadences. The fix is not to install more tools; it is to wire the ones you already have together at the release-event boundary. Three concrete checks for whether your setup has the gap: 1. Does your deploy script exit successfully without ever running a business-flow probe? If yes, you have the gap. 2. Can you point at a single metric on a graph from two hours ago and say which release that metric belongs to? If no, you have the gap. 3. Does your on-call alert threshold change at all in the first fifteen minutes after a release? If no, you have the gap. Closing those is the smallest investment that converts your VPS deploys from "fingers-crossed pushes" into "verified releases." Everything else — canaries, automatic rollbacks, SLO-aware gates — builds on top. --- ## Self-hosted cron and backup jobs need dead-man monitoring before silent failures become data loss Source: https://deploytovps.com/blog/guide/self-hosted-cron-backup-dead-man-monitoring Tags: monitoring, cron, backups, homelab, uptime-kuma Scheduled jobs that stop running silently are more dangerous than services that crash loudly. Here is how to build dead-man monitoring for your cron jobs, backups, and maintenance tasks before a missed run turns into actual data loss. Scheduled jobs that stop running silently are more dangerous than services that crash loudly — a crashed service generates an alert, but a cron job that quietly stops produces nothing until you notice the consequences. ## The silent failure problem A recurring pattern in self-hosted and homelab communities is operators discovering their backup, sync, or maintenance jobs stopped working weeks ago. Not because the server went down — the uptime page was green the whole time. The job just stopped producing output, or the binary it called changed behavior after a system update, or a filesystem ran out of space partway through and the script exited non-zero with no one listening. An uptime page tells you whether a port is answering. It tells you nothing about whether your nightly Postgres dump completed, whether your offsite rsync transferred any data, or whether your database import script actually inserted rows. Those are fundamentally different questions, and they require a different monitoring primitive: dead-man switches. A dead-man switch works in reverse from a health check. Instead of your monitor poking a service and waiting for a response, the job itself checks in with a monitoring endpoint after each successful run. If the check-in does not arrive within the expected window, the monitor fires an alert. The job proves it ran; silence proves it did not. ## What needs dead-man coverage If you are running any of the following, they belong on a dead-man monitor: - **Database backups** — nightly `pg_dump`, `mysqldump`, or equivalent. These are the highest-stakes jobs on any server. A missed backup discovered after a disk failure is catastrophic. - **Offsite sync scripts** — `rsync`, `rclone`, or `restic` jobs pushing data to a remote location. The job completing locally is not the same as the data reaching the destination. - **Scheduled imports and exports** — ETL jobs, feed ingestion, report generation. When these silently stop, downstream consumers get stale data with no indication anything is wrong. - **Maintenance tasks** — log rotation, temp directory cleanup, certificate renewal. `certbot renew` failing silently means you discover the problem when the certificate expires and your site goes HTTPS-broken for users. - **Container health checks** — a container can report running while the application inside is wedged. Scheduled health probes that check actual application behavior are worth treating as jobs with expected completion windows. A homelab showcase in the selfhosted community illustrated the full picture well: asset health, SSL and domain expiry tracking, container status timelines, and recovery visibility all in one place. The point was not vanity — it was that each of those dimensions can fail independently, and you need coverage for all of them. ## Building the monitoring layer The practical stack for most self-hosted operators: **Uptime Kuma with push monitors** is the most common free option. Create a monitor of type "Push" and Kuma gives you a URL. Add a `curl` call to that URL at the end of your cron script. If Kuma does not see a hit within the configured heartbeat interval, it alerts. The setup for a backup job looks like: ```bash #!/bin/bash set -e pg_dump mydb | gzip > /backups/mydb-$(date +%Y%m%d).sql.gz curl -fsS --retry 3 "https://uptime.example.com/api/push/YOUR_MONITOR_KEY?status=up&msg=OK" > /dev/null ``` The `set -e` is important. Without it, a failed dump might still reach the curl line and report success. With it, any non-zero exit aborts the script and the check-in never happens — which is exactly the behavior you want. **Failure context matters as much as the alert.** Knowing a job did not check in is useful. Knowing why requires logging. Redirect both stdout and stderr to a log file, and include the log location in your alert message or dashboard. A mobile-first homelab NOC that operators actually use during an incident needs more than a red dot — it needs the last known error context. **Keep at least one monitor off the server being monitored.** A homelab setup worth emulating runs its status page on an external VPS specifically so the status page survives local downtime. If your Uptime Kuma instance is on the same host as the jobs it monitors, a host failure takes down both the jobs and the monitoring simultaneously. You learn about the outage when someone else tells you, not from your own alerts. [ServerCompass](https://servercompass.app) gives you a dashboard view across your running services, so you can see at a glance which processes are active and correlate job failures against service state — useful when a backup fails because the database service it depends on was restarting at the wrong moment. ## Failure recovery workflow Monitoring is only useful if the alert leads to action. Define the recovery path before you need it: 1. **Alert fires** — job did not check in within the window. 2. **Check logs** — what did the last run output? What exit code? 3. **Check dependencies** — is the database up? Is the target mount point mounted? Is there disk space? 4. **Re-run manually** — fix the underlying issue, run the job by hand, confirm it completes and the check-in fires. 5. **Review the schedule** — if the job runs as root crontab but the environment differs from your shell, that is a common source of silent failures. Always test cron jobs with `run-parts --test` or by temporarily adding verbose logging. For offsite jobs specifically, verify the data reached the destination — not just that the script exited zero. A successful `rclone` invocation with zero bytes transferred is still a failure in any meaningful sense. ## Checklist - Add a dead-man push monitor (Uptime Kuma or equivalent) for every cron-based backup, sync, and maintenance job - Use `set -e` in all shell scripts so partial failures abort the script before the check-in curl call - Log stdout and stderr for every scheduled job to a file with a date-stamped name - Run at least one monitoring instance on a host that is independent of the servers it monitors - Verify offsite sync jobs by checking the destination, not just the exit code - Track SSL and domain expiry separately from service uptime — both can fail independently - Define a written recovery runbook for each critical job before you need it under pressure - Test cron jobs explicitly in a clean environment to catch PATH and environment variable differences from interactive shell sessions --- ## Self-hosted recovery plans fail when freezes, vendor lockouts, and fragile boot media are treated as separate problems Source: https://deploytovps.com/blog/guide/self-hosted-recovery-plan-freezes-lockouts-boot-media Tags: recovery, proxmox, wordpress, backup, homelab Most self-hosters have backups somewhere, but few have a rehearsed path for diagnosing what went wrong, restoring service, and confirming everything is actually healthy again. Proxmox freezes, WordPress lockouts, and SD-card corruption are different symptoms of the same missing playbook. Most self-hosters have backups somewhere. What they usually lack is a rehearsed path for diagnosing what failed, restoring service from those backups, and then confirming that everything is actually healthy again — before the next Monday-morning freeze. ## The Four Failure Modes That Keep Appearing Recent Reddit threads surface the same cluster of problems across very different stacks: **Proxmox freezes with no visible logs.** A mini PC running Proxmox locks up every Monday before dawn. Nothing in the logs. The only recovery path is a physical reboot. The freeze itself is almost a secondary problem — the real gap is that there is no automated watchdog, no remote out-of-band access, and no documented procedure for what to do when the host is unresponsive. Someone has to physically be there. **WordPress vendor lockouts.** A site owner hired an SEO vendor who now controls the hosting account and refuses to hand over a backup or restore admin access. The owner has no independent copy of the database, no snapshot they control, and no fallback credentials. This is not a technical failure — it is an access-control failure that backups alone cannot fix. **Home Assistant boot media failures.** One user wrote a multi-year summary: SD-card corruption, overheated USB boot drives, missing off-host backups, and a long debugging session assisted by an LLM before reaching stable uptime. Each failure required starting from scratch rather than restoring from a known-good state, because the backups existed locally on the same media that had just failed. **Unraid kernel panics and filesystem corruption.** An upgrade triggered kernel panics and data corruption. Recovery required booting from a USB rescue environment and switching the filesystem. The Unraid case is a reminder that upgrade procedures without a pre-upgrade snapshot or rollback target are just optimism. None of these are exotic edge cases. They are the ordinary failure modes of self-hosted infrastructure, and they share a common root cause: each failure was treated as its own unique incident rather than as an instance of a general recovery pattern. ## Why Treating Them Separately Makes Each Worse When you treat a Proxmox freeze as a "Proxmox problem," you end up researching Proxmox-specific watchdog daemons without also asking whether you have out-of-band access (IPMI, a Raspberry Pi on the same network, a managed PDU) to cover any future unresponsive-host scenario regardless of what is running on it. When you treat the WordPress lockout as a "vendor problem," you focus on legal remedies rather than also building a policy that your own credentials and your own backup copies are non-negotiable for every hosted property going forward. When you treat the boot-media failure as an "SD card problem," you upgrade to eMMC or a more durable USB drive — but you may still leave the backup on the same physical host, which means the next failure (a power surge, a dropped NAS, a house fire) hits both the system and the backup simultaneously. The recovery plan that actually holds up is one that covers the whole class of failure: **I cannot access or trust this host. How do I restore service and verify it is healthy?** A tool like [ServerCompass](https://servercompass.app) helps on the visibility side — it lets you see all your running services at a glance from a single dashboard, so you know immediately which hosts and services are affected when something goes wrong, rather than discovering the scope of an outage incrementally. ## Building a Recovery Plan That Holds A useful recovery plan has three layers: **access**, **restore**, and **verify**. **Access** means you can reach the system even when the OS is unresponsive. For physical hardware, that is IPMI or a Pi-based KVM. For a VPS, that is the provider's out-of-band console (Hetzner's rescue system, DigitalOcean's recovery console, etc.). For hosted services, that means you have independent credentials — your own account at the registrar, your own copy of DNS records, your own database export — not just admin access inside a third-party control panel. **Restore** means you have a tested, off-host backup that you can actually use. "Off-host" is load-bearing: the Unraid corruption and the Home Assistant boot-media failures both demonstrate that a backup on the same physical device is not a backup for the failure modes that matter most. Off-host means a different machine, a different datacenter, or a cloud object store. Tested means you have run a restore drill at least once and it produced a working system. **Verify** means after you restore, you confirm the service is actually functioning — not just running. A process that is up but serving errors is not a recovered service. Verification can be as simple as a curl health-check script and a quick walk through the UI, but it needs to be part of the documented procedure, not an afterthought. For the Proxmox freeze case specifically: a watchdog daemon (Proxmox has one built in) combined with IPMI or a remote power switch eliminates the "someone has to physically be there" requirement. That is not a Proxmox fix — it is an access-layer fix that applies to any bare-metal host. ## Rehearsal Is the Missing Step The Home Assistant user's multi-year summary is instructive: each failure taught them something, but the learning came from the failure itself rather than from a rehearsal before the failure. By the time they reached stable uptime they had effectively run a recovery drill — just involuntarily. Scheduling a deliberate restore drill twice a year forces you to discover the gaps before production does. Pick a non-critical VM or a staging environment. Restore from your off-host backup without using anything on the failed host. Verify the result. Document what broke in the procedure. Fix it. The drill also answers the hardest question in a real incident: not "do I have a backup?" but "how long does a restore actually take, and what do I need to have ready before I start?" A recovery plan that has never been run is not a plan. It is a note. ## Checklist - Confirm you have out-of-band access to every host (IPMI, provider rescue console, or a network-attached KVM) before the next freeze, not after. - For every hosted service managed by a vendor, hold your own copy of credentials, DNS records, and database exports — independent of whatever access the vendor has. - Store at least one backup copy off-host: a different physical machine, a different datacenter, or a cloud object store. - Before any major upgrade (kernel, Unraid version, Home Assistant major release), take a snapshot or backup you can roll back to, and document the rollback steps. - Set up a watchdog or health-check for any host that cannot be physically reached quickly — Proxmox has a built-in watchdog; use it. - Run a restore drill at least twice a year: restore from off-host backup to a clean environment and verify the service is functional, not just running. - Use a monitoring dashboard (such as [ServerCompass](https://servercompass.app)) to track service health across hosts so you know the scope of an outage immediately. - Document the recovery procedure, including estimated time and required resources, so you are not improvising during the incident. --- ## Home access breaks at the edge when DDNS, tailnets, reverse proxies, and cert verification drift apart Source: https://deploytovps.com/blog/guide/self-hosted-remote-access-ddns-tailscale-reverse-proxy Tags: ddns, tailscale, reverse-proxy, ssl, remote-access Self-hosted remote access fails in predictable ways: DDNS containers that think nothing has changed, reverse proxies that route correctly inside the network but break at the edge, and WireGuard tunnels that work from everywhere except the one location you frequent most. This guide walks through each failure pattern and the checks that catch them before they strand you. Self-hosted remote access fails in predictable ways: DDNS containers that think nothing has changed, reverse proxies that route correctly inside the network but break at the edge, and WireGuard tunnels that work from everywhere except the one location you frequent most. The common thread is that each layer — DNS, the tunnel or tailnet, the reverse proxy, and the TLS certificate — has its own idea of the current state, and when those ideas drift apart, access breaks at the worst possible time. ## DDNS containers that don't know they're wrong DDNS clients work by comparing the IP they last set against the current public IP, then pushing an update only when those differ. The problem is that "the IP they last set" is often cached locally in a state file, not read back from the DNS provider. If you manually change the DNS record to test something, or if the provider's API returns a stale record, the client sees no delta and sends no update. One user discovered this before a month-long trip: ddclient running in Docker believed the Cloudflare record was already correct, even after a manual DNS edit. The container's cached state and the live DNS record had diverged, but nothing in the normal log output made that obvious. The fix is to force an update on startup and to periodically verify by querying the authoritative nameserver directly — not the local resolver, which may serve a cached answer. Add `--force` to the ddclient invocation or set `daemon=300` with `force=yes` in the config. Then run a cron job that does `dig @ yourdomain.com +short` and compares it against `curl -s https://ifconfig.me`. If they don't match, send an alert. Do not rely on the client's own log to tell you the record is stale. ## Fedora, Tailscale, and the one-URL-for-all-services problem Putting many self-hosted services behind a single public URL over a Tailscale network is a reasonable goal. The execution is where days disappear. A user on Fedora 44 spent significant time trying both Caddy and Traefik as the reverse proxy, running into the same class of problem with each: the proxy worked inside the tailnet but traffic from outside never arrived cleanly. The pattern here is usually one of three things. First, the proxy is bound to the wrong interface — listening on `127.0.0.1` or the LAN interface, not the Tailscale IP (`100.x.x.x`). Second, Tailscale's subnet routing or the exit-node configuration is not advertising the routes that would make the proxy reachable. Third, Fedora's firewalld is dropping traffic on the Tailscale interface because it's assigned to the default zone instead of a trusted one. To debug this in sequence: check `tailscale status` to confirm the machine is reachable and that any subnet routes are active and accepted by the admin console. Then check `ss -tlnp` on the proxy host to confirm it's listening on the Tailscale IP. Finally, run `firewall-cmd --list-all --zone=FedoraWorkstation` (or whatever zone Tailscale landed in) and confirm traffic on port 80 and 443 is accepted. Caddy's automatic HTTPS will try to provision a cert, which requires outbound port 80 and 443 to the ACME endpoint — that too can be blocked at the firewall. ServerCompass lets you see all your running services at a glance, which helps when you're unsure whether a proxy process is actually up or whether a port is bound where you expect it. ## WireGuard unreachable from a same-ISP location A home WireGuard setup with a static IP can be unreachable from a specific location for reasons that have nothing to do with the VPN config itself. One self-hoster found that their home network was reachable from most places but not from the ISP location they use frequently. The static IP was correct, the WireGuard config was unchanged, and handshakes succeeded from elsewhere. Same-ISP routing is the likely culprit. Some ISPs route traffic between customers on the same network segment through internal paths that bypass normal internet routing. If that internal path drops UDP, or if the ISP applies port filtering that blocks non-HTTP traffic between residential subscribers, WireGuard packets never arrive even though the endpoint is technically reachable. Three checks worth running from the affected location: `traceroute -U -p 51820 ` to see where UDP packets die, `curl -sI https://yourdomain.com` to verify TCP works, and a quick `wg show` on the home server to see whether handshakes are being attempted at all. If traceroute shows the path staying inside the ISP's AS and dying there, the fix is usually to move WireGuard to port 443 (which many ISPs treat differently than high-numbered UDP ports) or to route the traffic through a small VPS that sits outside the ISP's network — a $5/month relay is cheaper than the debugging time. ## The cert verification problem nobody talks about HAProxy with ACME is a well-documented setup, but one DevOps practitioner surfaced an underappreciated problem: completing the ACME challenge is easy; proving that the live certificate was actually replaced in production is hard. ACME clients report success when the certificate is issued and written to disk. They do not verify that HAProxy reloaded, that the reload read the correct file path, or that intermediate proxy layers (CDN, upstream load balancer) are not still serving the old certificate. A successful renewal and a running service can coexist with clients still seeing the expired cert for hours. The right check is to query the certificate the server is actually presenting, not the one on disk. `openssl s_client -connect yourdomain.com:443 -servername yourdomain.com /dev/null | openssl x509 -noout -dates` returns the expiry of the certificate the server handed to the TLS handshake. Run this immediately after a renewal and compare it against the expected `notAfter` date. If they differ, HAProxy did not reload cleanly. Check `haproxy -c -f /etc/haproxy/haproxy.cfg` for config errors that silently prevent reload, and confirm the cert file path in the HAProxy config matches where the ACME client wrote the renewed certificate. For CDN-fronted setups, query the origin directly by bypassing the CDN (`curl -sI --resolve yourdomain.com:443: https://yourdomain.com`) to separate a CDN caching issue from an origin problem. ## Checklist - Verify DDNS accuracy by querying the authoritative nameserver directly (`dig @ yourdomain.com +short`) and comparing against `curl -s https://ifconfig.me` — do not trust the client's cached state. - Force a DDNS update on container startup and add a cron job that alerts when the DNS record and actual IP diverge. - When a Tailscale reverse proxy is reachable inside the tailnet but not from outside, check interface binding (`ss -tlnp`), subnet route advertisement in the Tailscale admin console, and firewalld zone assignments. - On Fedora, ensure the Tailscale interface is in a zone that permits inbound traffic on ports 80 and 443. - For WireGuard unreachable from one ISP, run `traceroute -U -p 51820` from the affected location to identify whether traffic is dying inside the provider's AS; consider moving to port 443 or using a VPS relay. - After any TLS certificate renewal, verify the live certificate using `openssl s_client` against the actual hostname — do not rely on the ACME client's success message alone. - For HAProxy, confirm the process reloaded successfully after cert renewal and that the cert file path in the config matches where the ACME client wrote the new certificate. - Use ServerCompass to get a single-pane view of running services and open ports so you can confirm process state and interface binding without jumping between multiple SSH sessions. --- ## Beginner self-hosters are choosing storage before they know the app data, backup, and migration boundaries Source: https://deploytovps.com/blog/guide/self-host-storage-planning-before-buying-nas Tags: immich, nas, storage, jellyfin, backup Most beginners pick their NAS or external drive before they understand where Immich, Jellyfin, and their backup tools actually store data. Get the storage boundaries clear first, then buy the hardware. Most beginners pick their NAS or external drive before they understand where Immich, Jellyfin, and their backup tools actually store data — and that ordering causes problems later. The pattern repeats across every homelab and self-hosting forum. Someone knows they want Immich for photos, Jellyfin for media, maybe a game server or two, and they want backups. Then they start a thread asking whether they should use a 3TB HDD plus a 2TB SSD, an old Buffalo NAS, a Raspberry Pi NAS, or a portable SSD connected to a single-board computer. The hardware question is legitimate, but it is being asked before the more important question: where does each piece of software put its data, and how does that shape what you need to back up and how you migrate later? ## What actually lives where Before you buy a single drive, you need to map out three separate things for each app you plan to run: the database, the media files, and the app config. For Immich specifically: - The **Postgres database** holds all your metadata — albums, tags, face clusters, shared links, user accounts. It lives in a Docker volume by default, typically something like `immich_postgres_data`. It is not inside your photo library. - The **photo and video library** is a large directory you mount into the Immich container. This is the bulk of your storage. - The **machine learning models** are a third location, usually a named volume. This matters immediately when you try to move things. overflow74 hit Postgres permission errors after trying to move a portable Immich external-SSD setup onto a new SBC. The likely cause: the Postgres data directory got copied to a new location but the ownership or mount path changed, so the database container could not start. If you had understood that the database and the photo library are two separate things with different portability characteristics, you would have handled them differently during the move. For Jellyfin: - The **config and metadata** directory contains your library database, transcoding settings, and plugin data. - The **media files** themselves can live anywhere — an NFS share, a local disk, a SAMBA mount. Jellyfin reads them but does not own them. File Browser and game servers follow the same pattern: there is always config, often a database or state file, and then the actual content. ## Why hardware decisions come second Once you know what needs to be stored, the hardware question becomes much easier to answer. A common beginner setup question: should I put everything on one 3TB HDD with a 2TB SSD as backup/cache? The problem with answering that before the boundary analysis is you end up mixing app databases, media libraries, and backup destinations on the same pool without thinking about failure modes. Consider what happens if that single 3TB HDD fails. You lose: 1. The Immich Postgres database (all your albums, tags, face data) 2. The photo library itself 3. The Jellyfin metadata and config 4. Your game server state The 2TB SSD only helps if you actually set up something to replicate to it — it is not automatic just because it is in the same machine. A better starting point for a beginner multi-app server: - Run app databases and config on the faster SSD or on the boot drive - Put media libraries (photos, videos) on the large spinning disk - Back up databases separately from media, because they change constantly and are small, while media changes less often and is large For someone like txprphan who is new to networking and considering Buffalo NAS hardware: NAS appliances are fine for bulk storage, but if you run Immich or Jellyfin on a separate host and point them at the NAS over NFS or SMB, you still need to back up the databases that live on the separate host. The NAS handles the media, but not the metadata. ## The migration and backup boundaries that bite people The single biggest source of grief when moving a self-hosted setup is misunderstanding what needs to move together. For Immich, a safe migration means: 1. Stop all Immich containers 2. Export or snapshot the Postgres database 3. Move the photo library 4. Update your `docker-compose.yml` mount paths 5. Restore the database on the new host 6. Start containers and verify Skipping step 2 or conflating it with step 3 — for example, just rsyncing the whole external SSD and hoping Docker picks it up — is what causes permission errors and broken containers. For okdeparture2596, who has photos split across a 10-year-old NAS and iCloud and wants to consolidate into Immich for album sharing: the migration plan needs to account for where Immich will store its database on the new hardware, not just where the photos will land. If the NAS is being replaced, does the Postgres data need to move too, or is Immich running on a separate host? A tool like [ServerCompass](https://servercompass.app) helps here because it lets you see all your running services and their resource usage at a glance — so when you are planning a migration, you can confirm what is actually running where before you start moving things around. ## Corruption risks and multi-disk setups One common worry in beginner threads is corruption risk when using multiple drives — for example, a 3TB HDD and a 2TB SSD in a Proxmox backup storage setup. The corruption concern is legitimate but often framed wrong. The risk is not that using two drives causes corruption. The risk is: - Running databases on a drive that loses power without a clean shutdown - Using a filesystem that does not protect against partial writes (ext4 without journaling, FAT32) - Not having a backup so that corruption is unrecoverable For Proxmox + Immich + Jellyfin on the same host: - Use ext4 or ZFS for your data drives (both journal writes) - Put your Postgres volumes on the SSD for speed and lower failure rate - Use Proxmox Backup Server or a cron-based `pg_dump` to dump databases to a separate destination regularly - Treat the HDD as media-only storage — large sequential reads and writes, no database I/O This is not about preventing all possible failure. It is about making sure that when something fails — and it will — you know exactly what was lost and you can restore it. ## Checklist - Map out the database location, media location, and config location for every app before choosing hardware - Do not conflate the Immich photo library with the Immich Postgres database — they are separate and need separate backup strategies - For Jellyfin, keep config/metadata on a fast drive separate from your media files - If you add a NAS for bulk storage, remember the app databases on your main host still need to be backed up - Use `pg_dump` or Docker volume snapshots for database backups on a schedule — rsync alone is not sufficient for live databases - When migrating Immich to new hardware: stop containers, export the database, then move the library, then restore - Test your restore process before you need it — confirm you can actually start Immich from a database dump and a media directory on a fresh host - Use a service overview tool like [ServerCompass](https://servercompass.app) to confirm what is running where before any migration --- ## Shared-hosting lockouts make beginner web projects need recoverable deployment exits Source: https://deploytovps.com/blog/guide/shared-hosting-lockouts-recoverable-deployment-exit Tags: hosting, wordpress, vps, backups, migration Beginners and agencies on cheap or contractor-managed hosting regularly lose access to their own data, backups, and admin rights — often at the worst moment. Here is how to structure your deployments so a lockout is an inconvenience, not a catastrophe. Beginners and agencies on cheap or contractor-managed hosting regularly lose access to their own data, backups, and admin rights — often at the worst moment, right before a deadline or client review. ## What actually happens during a shared-hosting lockout The pattern shows up repeatedly in beginner webdev and WordPress forums. A student developer signs up with a budget shared-hosting provider, their site starts getting traffic, and the host throttles CPU, restricts the account, and closes support tickets without explanation. The account is effectively frozen. The host demands a signed apology letter before restoring access. Refunds are refused and data access is withheld. A separate but related thread describes a WordPress agency whose client had hired a shady SEO vendor. When the client relationship soured, the vendor refused to hand over backups or admin credentials. The host had to intervene, and domain ownership turned out to be the only reliable recovery lever the client held. These are not freak accidents. They are predictable failure modes of setups where someone else controls the exit. ## Why cheap shared hosting makes the exit harder Shared hosting is appealing for beginners because it abstracts away the server. You get a control panel, one-click installers, and a low monthly bill. The tradeoff is that the host owns the environment. When things go wrong — account suspension, billing dispute, host going under — you are a tenant with no keys. The lockout scenarios above share a structural problem: the person who built the project did not own a clean copy of the data. Backups existed, but they were inside the host's infrastructure. The domain was managed by a third party. Admin credentials were held by a contractor. Any one of those missing pieces was enough to create a weeks-long recovery process. VPS hosting does not automatically solve this, but it shifts control. You own the operating system layer, the file system, and the network config. If a provider treats you badly, you can snapshot the volume (on most cloud providers), provision a new server, restore the snapshot, and update DNS. That path requires preparation, but at least the path exists. ## Building a recoverable deployment from the start The goal is to make every deployment trivially re-deployable from outside the current host. That means a few concrete habits: **Own your domain registration separately from your hosting.** The WordPress agency case above was ultimately resolved because the client still controlled the domain. That one fact gave them leverage and a recovery path. Use a registrar that you pay directly — Cloudflare Registrar, Namecheap, or similar — and never hand over registrar credentials to a contractor. **Keep your code in a repository you control.** A GitHub or GitLab repo that you own means you can re-deploy to any server in minutes. If the host disappears or locks you out, your codebase is not gone with it. For WordPress sites specifically, track your theme and plugin customizations in version control even if the host offers a GUI backup tool. **Run scheduled database exports off-site.** A cron job that dumps your database and pushes the file to object storage (Backblaze B2, AWS S3, Hetzner Object Storage) gives you a copy that survives whatever happens on the server. For WordPress, plugins like UpdraftPlus can automate this to an external bucket. On a VPS, a simple `pg_dump` or `mysqldump` piped to `rclone` does the same thing. **Document your stack so you can reproduce it.** A single README that lists your Node version, environment variables (names, not values), database engine, and the commands to start each service is worth hours of recovery time. Tools like ServerCompass let you see all your running services at a glance, which makes auditing what you actually have deployed much faster than logging in and running `ps aux` from memory. **Test the restore path before you need it.** Spin up a cheap VPS, restore your latest backup, and confirm the site comes up. If it does not, you have found the gap while it is still a drill and not a crisis. ## When a contractor or vendor holds your credentials The WordPress agency scenario highlights a power dynamic that catches clients off guard. An SEO vendor or developer with admin access can become a de facto gatekeeper if the engagement ends badly. Prevention is straightforward but easy to skip when the project feels friendly: - Create a separate admin account for any contractor. Never give them your primary admin credentials. - Set a calendar reminder to rotate or remove contractor access when an engagement ends. - For WordPress specifically, keep a local export of the database taken by you (not the contractor) at least monthly. - Register the domain in your own name, with your own email, at a registrar you control. If you are already locked out, the host's abuse or trust and safety team is usually the first call. Domain ownership is your legal leverage if the dispute escalates. A registrar transfer can sometimes be initiated even without the current admin's cooperation if you can prove registrant identity. ## Checklist - Register your domain at a registrar you control directly, not through a hosting package or contractor - Store all code in a version-controlled repository (GitHub, GitLab) that you own - Schedule automated database exports to off-site object storage (Backblaze, S3, Hetzner) - Use a separate admin account for contractors and remove access at the end of each engagement - Keep a local copy of your database export taken by you, not a third party - Document your stack (services, ports, env var names, start commands) in a README or runbook - Use a tool like ServerCompass to audit running services so your documentation stays accurate - Test your restore process on a fresh VPS before an actual emergency forces you to --- ## Tailnet self-hosting still needs boring HTTPS and reverse-proxy guardrails Source: https://deploytovps.com/blog/guide/tailnet-self-hosting-https-reverse-proxy-guardrails Tags: https, tailscale, reverse-proxy, ssl, self-hosting Getting Jellyfin reachable over Tailscale is satisfying, but the moment you add an app that requires HTTPS or a clean hostname, the cracks appear. This guide covers the certificate, routing, and observability steps that keep a tailnet setup maintainable. Getting a service reachable over Tailscale feels like a win — and it is — but a surprising number of self-hosters hit a wall the moment their second or third app demands HTTPS, a real hostname, or root-path routing. ## The gap between "it works on the tailnet IP" and "it actually works" The classic entry point is Jellyfin: you expose it on a tailnet IP, it loads in the browser, and everything seems fine. Then you try Obsidian sync or Zotero — apps that are stricter about HTTPS — and the setup falls apart. Worse, some apps silently misbehave rather than throw a clear error, so you end up debugging why sync is failing rather than why the certificate is missing. The subpath problem is a related trap. Caddy makes it easy to route `/jellyfin` to one container and `/nextcloud` to another, but a lot of self-hosted apps assume they live at the root (`/`). They generate internal links like `/static/app.js` instead of `/jellyfin/static/app.js`, and suddenly half the UI breaks. The fix — configuring a base URL or subpath prefix inside each app — is documented, but it is not obvious unless you already know to look for it. The cleaner alternative is one subdomain per service, which sidesteps the rewrite problem entirely. A Home Assistant user captured the fragility of skipping this groundwork: a TLS certificate error appeared at work, cutting off remote access entirely, with no warning beforehand. Certificate expiry is a slow-moving problem that looks fine until it is not. ## Certificate issuance is the easy part; proving it serves correctly is harder ACME and Let's Encrypt have made certificate issuance nearly automatic. The hard part, as a DevOps write-up on HAProxy ACME noted, is *proving* that production is actually serving the new certificate rather than just completing the ACME challenge. There are three distinct things to verify: 1. **The challenge succeeded** — ACME returned a certificate. 2. **The reverse proxy loaded the new cert** — a reload or restart picked it up. 3. **The client sees the new cert** — an external `openssl s_client` or `curl -v` confirms the correct expiry date. Step 3 is the one most people skip. Running `openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null | openssl x509 -noout -dates` takes ten seconds and tells you exactly what the client receives, not what you think you deployed. For Tailscale-internal services, Tailscale's built-in MagicDNS and HTTPS certificates (enabled in the admin console) handle issuance for `.ts.net` hostnames without any ACME configuration on your part. That covers the tailnet-only case. If you want external access or vanity subdomains, you still need a standard ACME setup with a reverse proxy in front. ## Structuring the reverse proxy so it stays maintainable A common pattern that works well at small scale: - One subdomain per app (`jellyfin.home.example.com`, `ha.home.example.com`). - Caddy or nginx terminates TLS and proxies to `localhost:` or a Docker container. - Certificate renewal is automated via Certbot or Caddy's built-in ACME client. - A monitoring check runs weekly to confirm each cert's expiry date. The subdomain approach costs a wildcard certificate or individual certificates per subdomain, but it eliminates the subpath rewriting headache and makes each service independently movable — you can change the backend port or host without touching other services. For HAProxy users, the ACME plugin handles renewal, but the config gets verbose quickly. The maintainability win comes from keeping each backend in its own named section and adding a comment with the cert path and renewal method. Future-you will thank present-you. Keeping these services visible in one place also matters. [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — ports, process status, uptime — which makes it much easier to spot when something went quiet without you noticing. ## Making renewals observable before they become incidents Certificate expiry incidents are nearly always preceded by a period where the cert was silently aging without anyone checking. The Home Assistant cutoff at work is a textbook example: the cert expired, access stopped, and the user had no warning. Three lightweight practices prevent this: **Set a calendar reminder or cron check.** Let's Encrypt certs are valid for 90 days. Certbot's auto-renew runs twice daily by default but only renews when the cert is within 30 days of expiry. A simple weekly cron that runs `certbot certificates` and logs the output gives you a paper trail. **Add an expiry alert to your monitoring.** Tools like UptimeRobot (free tier) support SSL expiry checks. Set an alert at 21 days. This catches cases where auto-renew failed silently — often because the DNS challenge timed out or the HTTP challenge was blocked by a firewall rule change. **Test the renewal path before it matters.** Certbot has a `--dry-run` flag: `certbot renew --dry-run`. Run it after any firewall or nginx config change to confirm the challenge path still works. HAProxy users can test the ACME plugin's renewal path similarly. For Tailscale's built-in certs, the renewal is managed by Tailscale itself. You still want to verify the cert is being served correctly, but the operational burden is lower. ## Checklist - Enable MagicDNS and HTTPS certificates in the Tailscale admin console for tailnet-internal services — this handles issuance automatically for `*.ts.net` hostnames. - Use one subdomain per app rather than subpaths; configure the base URL inside each app to match. - After any ACME renewal, confirm the client sees the new cert: `openssl s_client -connect :443 -servername 2>/dev/null | openssl x509 -noout -dates`. - Add a weekly cron to log `certbot certificates` output so you have a visible record of cert state. - Set up an external SSL expiry monitor (UptimeRobot or similar) with a 21-day alert threshold. - Run `certbot renew --dry-run` after any nginx, Caddy, or firewall config change to confirm the challenge path still works. - Use [ServerCompass](https://servercompass.app) to keep a live view of running services and ports, so a silently-stopped process does not go unnoticed. - Document the cert path and renewal method in a comment next to each reverse-proxy backend block. --- ## Unraid 7.3 upgrade and recovery threads need a preflight for webUI lockups, missing drives, and power-loss corruption Source: https://deploytovps.com/blog/guide/unraid-73-upgrade-recovery-preflight-webui-lockups Tags: unraid, upgrade-preflight, backup, restore, home-server Upgrading Unraid to 7.3.x has a pattern of post-reboot surprises: a frozen webUI, an array where every drive shows missing, or a USB boot drive corrupted by forced power cycles. Running a short preflight checklist before you touch the upgrade button is the difference between a smooth evening and a multi-day recovery. Upgrading Unraid to 7.3.x has a pattern of post-reboot surprises: a frozen webUI, an array where every drive shows missing, or a USB boot drive corrupted by forced power cycles. Running a short preflight checklist before you touch the upgrade button is the difference between a smooth evening and a multi-day recovery. ## What the forums are actually reporting Three distinct failure modes have surfaced in fresh threads after the 7.3.x releases, and they are worth treating as a cluster rather than isolated edge cases. **webUI and Docker page lockups.** One thread describes Unraid 7.3.1 locking up the webUI so completely that even the Docker page becomes unresponsive. The usual escape hatches — clicking "Stop Array," gracefully stopping containers — are unavailable because the UI itself will not respond. The only exit is a hard power cycle, which carries its own risks (see the USB corruption section below). **Every array HDD showing as missing after upgrade.** A second thread walks through a jump from 7.0.0 to 7.3.1 where the array came back up with all hard drives flagged as missing. The drives were physically present and readable, but Unraid could not reconcile the array configuration from the older version. The only confirmed recovery path in that thread was downgrading back to 7.0.0 and restoring the previous flash backup. **Cascading Docker failures during a restore attempt.** A third thread describes a total-failure scenario: Docker services stop, the operator follows restore steps from the documentation, but each step leaves the server in a worse state than before. The uncertainty centers on a media stack (likely Plex or Jellyfin plus *arr services) where container volumes and paths need to be reassembled in the right order. None of these are theoretical risks. They are documented user experiences from the current release cycle. ## The USB boot drive corruption loop Unraid boots from a USB flash drive, and that design choice becomes a liability when the system cannot shut down gracefully. Forced reboots after a webUI lockup write incomplete data to the flash drive. Do that enough times and the drive itself becomes unbootable. One thread asks a pointed question: how do you stop the cycle of USB corruption after repeated forced reboots? The answer involves two separate mitigations. First, keep a current flash backup. Unraid's "Tools → Flash Backup" creates a ZIP of your USB contents. Download it before every upgrade and store it somewhere the NAS itself does not control — a separate machine, an external drive, or a cloud location. If the drive corrupts, you restore from the ZIP to a new USB key and you are back in business within minutes rather than hours. Second, consider the write-protect or RAM-mode options that reduce how often Unraid writes to the flash during normal operation. Fewer writes during a crash mean less corruption exposure. Check the Unraid documentation for your specific version, as the options have moved between releases. ## How to structure your preflight before upgrading The patterns above point to a preflight routine that takes about 20 minutes and pays for itself the first time something goes wrong. **Verify your flash backup is current.** Go to Tools → Flash Backup, download the ZIP, and confirm the timestamp. A backup from three months ago does not protect you from a configuration you added last week. **Record your array disk assignments.** Open Main and write down (or screenshot) every slot: which disk serial is in parity, which is in disk 1, disk 2, and so on. The missing-drives thread shows that Unraid can lose this mapping across certain version jumps. Having the ground truth on paper means you can re-assign manually if needed. **Export your Docker template XML files.** Your container configurations live in `/boot/config/plugins/dockerMan/templates-user/`. Copy that folder to a safe location. If Docker comes back empty after an upgrade, you can rebuild containers from these templates without re-entering every environment variable and volume path by hand. **Note your current Unraid version before you upgrade.** If you need to downgrade — as the 7.0.0 to 7.3.1 thread required — you need to know exactly which version to roll back to. Unraid's previous versions are available from the "Previous Versions" tab on the flash backup restore page. **Check your UPS configuration.** Forced reboots are the mechanism behind USB corruption. If your server is on a UPS, confirm it is correctly configured to initiate a graceful shutdown before battery runs out. If you do not have a UPS, that is a separate conversation, but at minimum make sure the server is not on a circuit that flips during normal household load. A tool like [ServerCompass](https://servercompass.app) can help you see all your running services at a glance before you start — it is worth confirming which containers are active so you know what needs to come back up cleanly on the other side of the upgrade. ## If you are already in recovery mode If you are reading this after the upgrade went sideways, the sequence that has worked for others is: 1. Do not keep trying new restore steps if each one leaves the system worse. Stop, document the current state, and decide on a rollback first. 2. Downgrade to your last known-good version using a flash backup from that era. This is why the version-specific backup matters. 3. Bring the array back up and verify parity before touching Docker. Parity is your data integrity guarantee; restore it before worrying about containers. 4. Re-add Docker containers one at a time using your saved template XMLs, starting with the most critical service. Confirm each one is healthy before adding the next. Cascading failures in recovery happen when operators try to fix everything simultaneously. The media stack thread is a clear example: stopping all services, attempting a restore, and re-adding containers in the wrong order produces a state that is harder to diagnose than the original failure. ## Checklist - Download a fresh flash backup ZIP from Tools → Flash Backup before starting any upgrade - Screenshot or write down every array disk slot assignment (serial numbers included) - Copy your Docker template XMLs from `/boot/config/plugins/dockerMan/templates-user/` to a location outside the NAS - Note the exact current Unraid version so you have a target if a downgrade is needed - Confirm your UPS is configured for graceful shutdown before upgrading - Use ServerCompass or a similar dashboard to record which services are running pre-upgrade - If a webUI lockup forces a hard power cycle, check USB drive health before the next boot - In recovery, restore array and verify parity before attempting to bring Docker containers back --- ## WordPress self-hosters still lack a boring local-to-production deploy and backup handoff Source: https://deploytovps.com/blog/guide/wordpress-self-host-local-production-deploy-backup Tags: wordpress, deployment, backup, staging, ops-handoff WordPress deployment between local and production is still an unsolved problem for most self-hosters — database state, uploads, and template changes are all tightly coupled, making any push feel risky. This guide covers the practical steps to make deploys and backups boring in the good way. WordPress deployment should be routine, but for most self-hosters it still feels like defusing a bomb every time you push a change from local to production. ## Why WordPress Deploys Feel Unsafe The root problem is coupling. When you build a WordPress site locally, the state you're working with is split across multiple places at once: the database holds your content, options, and shortcode definitions; the `uploads/` directory holds media; your theme directory holds CSS and template files; and installed plugins can scatter configuration across all three. None of these move together cleanly when you try to push to production. A developer who has wrestled with this describes it clearly: a simple theme change might depend on a shortcode registered by a plugin, a CSS variable set in the Customizer (stored in the database), and an image in uploads — all of which are out of sync the moment you rsync your files. Production gets the file, but not the context the file expects. This is not a bug in WordPress. It is a consequence of how WordPress was designed — as a system where the database *is* the site, not just a backing store. The implication for deployment is that you cannot treat it like a stateless application. You need a strategy that handles both the filesystem and the database, in the right order, every time. ## A Reliable Local-to-Production Push The practical approach is to separate what you deploy from what you sync, and to never overwrite production data you haven't explicitly migrated. **Files you should deploy from version control:** - `wp-content/themes/your-theme/` - `wp-content/plugins/` (only plugins you manage, not ones installed via the admin) - Any custom `wp-config.php` additions (never the credentials themselves) **Things you should migrate explicitly, not overwrite:** - The database — export from local with WP-CLI (`wp db export`), search-replace the local URL for the production URL (`wp search-replace`), then import on production - The `uploads/` directory — sync it separately with rsync or a plugin like WP Migrate, and only when you know new media was added The key habit: treat every deploy as two separate operations. First push the code via git or rsync. Then decide whether a database migration is needed for this change. Most theme and CSS changes need no database work at all. New custom fields, option-dependent features, and Customizer changes do. For the database migration step, always run it on a staging environment first if one exists. If you do not have staging, at minimum take a database dump before the migration and keep it somewhere you can restore from in under five minutes. ## Backups That Actually Protect You A question that surfaces repeatedly in WordPress communities is deceptively simple: where should backup files live? The wrong answer is: on the same server as the site. A disk failure, a botched deploy, or a ransomware attack takes both the site and the backup in one shot. The correct answer is: offsite, versioned, and tested. In practice for a self-hoster, that means: 1. **Automated scheduled exports** — WP-CLI makes this scriptable: `wp db export /backups/$(date +%Y%m%d).sql`. Run this daily via cron. 2. **Files backup** — rsync `wp-content/uploads/` to a remote destination on the same schedule. If your VPS provider offers snapshots, use those too — but as a supplement, not a replacement. 3. **Offsite storage** — push the exports to object storage (Backblaze B2, Cloudflare R2, or an S3-compatible bucket). A simple `rclone sync` command after each export handles this with minimal setup. 4. **Retention policy** — keep daily backups for 7 days, weekly for 4 weeks. Delete older ones automatically so storage costs don't grow unbounded. Once backups are running, test them. A backup you have never restored from is a backup you do not actually have. Run a restore drill every few months on a staging or local environment. If you are running multiple WordPress sites on the same VPS, [ServerCompass](https://servercompass.app) lets you see all your running services at a glance — useful for confirming that your backup cron jobs and WordPress processes are all healthy without SSHing into each server individually. ## Recovering Ownership When a Third Party Holds the Site One scenario that comes up more than it should: a client's site is locked behind a shady SEO company or a web agency that refuses to hand over credentials. The client cannot access wp-admin, the hosting control panel, or the domain registrar. This is a server operations problem masquerading as a relationship problem. The resolution path depends on what access you still have: **If you have SSH or FTP access to the server:** You can reset the WordPress admin password directly in the database. Connect to MySQL and run: ```sql UPDATE wp_users SET user_pass = MD5('newpassword') WHERE user_login = 'admin'; ``` Then immediately change the password again via wp-admin and rotate all API keys. **If you have control of the hosting account but not wp-admin:** Most control panels (cPanel, Plesk, or a plain VPS with phpMyAdmin) give you direct database access. The same SQL above applies. **If the third party controls the hosting account and the domain:** This is the hardest case. Contact the domain registrar directly — ICANN's transfer dispute process exists for this situation. For hosting, contact the provider's abuse or account ownership team with proof of original ownership (invoices, original registration emails, business documents). The preventive measure is straightforward: any WordPress site you are responsible for should have you listed as the registrar account owner, and the hosting account should be under your control or your client's direct control, not a vendor's. Vendors get a login, not ownership. ## Checklist - Separate file deploys (git/rsync) from database migrations — never conflate the two into one rsync command - Use `wp db export` and `wp search-replace` to migrate the database, replacing local URLs with production URLs before import - Always take a database snapshot before any production migration, stored somewhere you can restore from quickly - Run automated daily database exports via cron and sync them offsite with rclone to object storage - Keep `wp-content/uploads/` backed up separately on the same schedule as the database - Test restores on a staging or local environment at least quarterly — an untested backup is not a backup - Keep domain registrar and hosting account ownership under the site owner's direct control, not a vendor's - Use a tool like [ServerCompass](https://servercompass.app) to monitor all services on your VPS and confirm backup cron jobs are running --- ## Tiny zero-cloud production stacks still need boring SSL, backup, and alerting guardrails Source: https://deploytovps.com/blog/guide/zero-cloud-production-stack-ssl-backup-alerting Tags: zero-cloud, raspberry-pi, ssl, backups, monitoring Running a production site on Raspberry Pi-class hardware can cut your monthly cloud bill to near zero, but skipping SSL termination, backups, and alerting turns that cost win into an operational liability. Here is what a real software house running mail, MariaDB, analytics, and HAProxy on edge hardware gets right — and what you need to put in place before you call it production. Running a real production stack on a Raspberry Pi 4 and an Orange Pi edge node sounds like a stunt, but teams are doing it successfully — and the monthly cost difference versus a mid-tier cloud setup is not trivial. The catch is that cutting cloud spend does not mean cutting the boring guardrails that make a system actually production-grade. ## What a real zero-cloud software-house stack looks like One software house documented their setup in detail: a Raspberry Pi 4 as the primary node, an Orange Pi acting as an edge endpoint, SSD boot disks (not SD cards — a hard-learned lesson), and a UPS to handle power blips. On top of that hardware they run a full website, a mail stack, MariaDB, and self-hosted analytics. For network ingress they use HAProxy for SSL termination, with Certbot handling certificate issuance and renewal. Alerts go out over Telegram when something degrades. Backups run on a schedule. None of this is glamorous, but every piece of it matters. A separate homelab operator running a denser single-node Proxmox setup shows the same pattern at higher complexity: VLANs, WireGuard, HAProxy, a status page hosted on a cheap VPS, ArgoCD, Vault, SaltStack, Prometheus, Grafana, Loki, and Discord alerts. Different scale, same discipline — the observability and safety rails scale up proportionally with the stack. The shared lesson from both setups: the hardware is cheap, the operational scaffolding is not optional. ## SSL termination is not negotiable HAProxy in front of your services with Certbot-issued certificates is a well-proven pattern on constrained hardware. HAProxy handles TLS offloading efficiently even on ARM CPUs, and Certbot's renewal hooks integrate cleanly with it. The things that actually cause SSL failures in practice: - **Certificates expiring silently.** Certbot auto-renewal works until it does not — a port-80 block from a firewall rule change or a misconfigured deploy will cause a quiet renewal failure that you only notice when the cert expires. Add an expiry check to your alerting. - **HTTP left open alongside HTTPS.** HAProxy makes it straightforward to redirect all port-80 traffic to HTTPS. Do it from day one. - **Internal services exposed without TLS.** If you are running services that talk to each other over plain HTTP on a VLAN, at minimum make sure those interfaces are not reachable from outside. WireGuard for inter-node traffic is a reasonable choice here. Certbot renews on a timer, but the actual availability of port 80 on renewal day is your responsibility. ## Backups and the 3-2-1 minimum Running MariaDB on a Raspberry Pi with no offsite backup is not a cost-efficient setup — it is a data-loss event waiting for a hardware failure. SSD boot disks are more reliable than SD cards, but SSDs still fail, and power interruptions without a UPS corrupt filesystems. The minimum viable backup posture for a zero-cloud production stack: 1. **Daily database dumps** — `mysqldump` or `mariadb-dump` piped to a compressed archive, rotated locally. 2. **Offsite copy** — `rclone` to a cheap object store (Backblaze B2 runs around $0.006/GB/month, well within the spirit of a low-cost setup). This is your disaster recovery path. 3. **Backup verification** — actually restore from backup on a test instance periodically. A backup you have never restored is an untested assumption. If your UPS covers a power blip but not an extended outage, make sure your database is configured to survive an unclean shutdown without corruption. InnoDB's `innodb_flush_log_at_trx_commit=1` is conservative but safe. ## Alerting that actually pages you Telegram alerting is the right call for a small team running self-hosted infrastructure — it costs nothing, works from a simple bot API call, and reaches mobile. The software house in this pattern uses it for service-level alerts; the Proxmox-based setup uses Discord. Neither is wrong. What matters is that the alert fires before a customer tells you something is broken. Alerts worth setting up for a stack like this: - Certificate expiry (warn at 30 days, page at 7) - Service health checks (HAProxy can do this natively via its stats page and health-check directives) - Disk usage thresholds (SSD or SD card, it fills up) - Backup job completion and failure - CPU temperature on ARM hardware running under sustained load Prometheus, Grafana, and Loki give you a full observability stack if you have the node capacity for it. If not, even a simple shell script that curls a health endpoint and posts to Telegram on failure is better than no alerting. [ServerCompass](https://servercompass.app) lets you see all your running services at a glance from a single dashboard, which is useful when you are managing multiple processes across nodes and want one place to confirm everything is up without SSHing into each box. ## Checklist - Use SSD boot disks, not SD cards, for any node running a real database or persistent services - Add a UPS to handle power blips — filesystem corruption from unexpected power loss on a Pi is common - Terminate SSL with HAProxy + Certbot and redirect all port-80 traffic to HTTPS from day one - Monitor certificate expiry dates explicitly — don't rely only on Certbot's auto-renewal succeeding silently - Run daily database dumps and copy them offsite with rclone or equivalent - Verify backups periodically by actually restoring one - Set up Telegram or Discord alerting for service health, disk usage, and backup job status - Use a tool like ServerCompass to monitor all services across nodes from a single view