<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://sa.net/</id>
    <title>Riven Cloud</title>
    <updated>2026-07-24T17:45:49.299Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <author>
        <name>Riven Cloud OÜ</name>
        <email>sales@riven.cloud</email>
        <uri>https://sa.net/</uri>
    </author>
    <link rel="alternate" href="https://sa.net/"/>
    <link rel="self" href="https://sa.net/atom.xml"/>
    <subtitle>Riven Cloud runs premium KVM VPS in Tokyo and Singapore with top-tier three-network China-return route optimization — CTGNet (formerly China Telecom CN2 GIA), China Unicom Premium (CUP, AS9929 / AS10099), and China Mobile International N2 (CMIN2).</subtitle>
    <rights>© 2026 Riven Cloud OÜ</rights>
    <entry>
        <title type="html"><![CDATA[The 10 Best Self-Hosted Docker Apps in 2026]]></title>
        <id>https://sa.net/blog/best-self-hosted-apps/</id>
        <link href="https://sa.net/blog/best-self-hosted-apps/"/>
        <updated>2026-07-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Our picks for self-hosted Docker apps worth running on a VPS, from Vaultwarden to Forgejo, with the security and upkeep trade-offs stated plainly.]]></summary>
        <content type="html"><![CDATA[<p>A good self-hosted app for a VPS needs more than a Docker image. It should benefit from staying online or having a stable address, and it should come with an operational cost you understand before it goes on the public Internet.</p>
<p>We picked these apps for technical usefulness rather than GitHub stars, and we have tried to be straightforward about the trade-offs. One habit worth keeping throughout: a public VPS does not mean an app should accept anonymous users. Several entries belong behind authentication, HTTPS, or another access-control layer.</p>
<p>The short list is: Vaultwarden for passwords, Paperless-ngx for documents, FreshRSS for feeds, Forgejo for Git, Umami for analytics, changedetection.io for page monitoring, ntfy for push notifications, SearXNG for private metasearch, Actual for budget synchronization, and linkding for bookmarks.</p>
<h2 id="how-these-self-hosted-apps-were-selected">How these self-hosted apps were selected<a href="#how-these-self-hosted-apps-were-selected" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>We checked every project as of July 19, 2026 against five requirements: active maintenance, an open-source license verified in the canonical repository, an official Docker or self-host installation path, a reason to run continuously on an Internet-connected host, and an operational caveat worth planning around. “Best” here means the app clears those filters and earns its keep on an always-on server, with trade-offs we can explain honestly. The ten apps are listed in no particular order.</p>
<p><a href="https://awesome-selfhosted.net/" title="Awesome-Selfhosted" target="_blank" rel="noopener noreferrer">Awesome-Selfhosted</a> was the discovery baseline. As of July 19, 2026, it contained 1,249 software entries across 94 categories, including 698 Docker-tagged entries; its <a href="https://github.com/awesome-selfhosted/awesome-selfhosted" title="GitHub repository" target="_blank" rel="noopener noreferrer">GitHub repository</a> had about 306,462 stars when checked. Use the recommended HTML catalog for browsing. The GitHub Markdown list is generated and retained as a legacy interface.</p>
<p>Those numbers describe the catalog as a whole; a listing is a discovery signal, not a security audit or a deployment endorsement. License labels can also lag canonical sources. As of the same July 19, 2026 check, Vaultwarden is listed there as GPL-3.0 although its repository declares AGPL-3.0, while Forgejo is listed as MIT although its canonical Codeberg repository uses GPL-3.0-or-later. Those mislabels are why we verified each license at the source.</p>
<h2 id="quick-comparison">Quick comparison<a href="#quick-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The table describes workload shape rather than invented RAM minimums. A mostly idle service can become busy with many users, aggressive polling, large imports, or long retention.</p>


















































































<table><thead><tr><th>App</th><th>Use</th><th>Deployment shape</th><th>Main VPS pressure</th><th>Biggest caveat</th></tr></thead><tbody><tr><td>Vaultwarden</td><td>Password sync</td><td>App container plus reverse proxy</td><td>Database, attachments, security</td><td>Unofficial Bitwarden API implementation and a high-value target</td></tr><tr><td>Paperless-ngx</td><td>OCR document archive</td><td>App, broker, database; optional converters</td><td>OCR bursts and document storage</td><td>Documents are stored in clear text</td></tr><tr><td>FreshRSS</td><td>RSS aggregation</td><td>PHP app plus supported database</td><td>Polling, scraping, retention</td><td>Slow or broken feeds need operator attention</td></tr><tr><td>Forgejo</td><td>Git hosting</td><td>App plus SQLite or external database</td><td>Repositories, LFS, packages, artifacts</td><td>Actions runners are separate systems</td></tr><tr><td>Umami</td><td>Web analytics</td><td>Node application plus PostgreSQL</td><td>Event ingestion and retention</td><td>PostgreSQL is required</td></tr><tr><td>changedetection.io</td><td>Page-change alerts</td><td>App plus optional browser fetcher</td><td>Chromium CPU and memory, outbound requests</td><td>Datacenter IPs may be blocked</td></tr><tr><td>ntfy</td><td>Push notifications</td><td>Go server with SQLite by default</td><td>Retained messages, attachments, transfer</td><td>Permissive defaults can expose topics</td></tr><tr><td>SearXNG</td><td>Metasearch</td><td>App plus Valkey for the limiter</td><td>Outbound queries and abuse control</td><td>Still depends on upstream search engines</td></tr><tr><td>Actual</td><td>Budget sync</td><td>Actual server container</td><td>Sensitive sync data and backups</td><td>It is a private sync service, not a public portal</td></tr><tr><td>linkding</td><td>Bookmark management</td><td>App with SQLite by default</td><td>Archive snapshots and persistent data</td><td>Chromium archive image is materially heavier</td></tr></tbody></table>
<h2 id="1-vaultwarden-password-synchronization">1. Vaultwarden: password synchronization<a href="#1-vaultwarden-password-synchronization" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><a href="https://github.com/dani-garcia/vaultwarden" title="Vaultwarden" target="_blank" rel="noopener noreferrer">Vaultwarden</a> gives individuals, families, and small teams a lightweight sync server for the Bitwarden clients they already use. It is an unofficial implementation of the Bitwarden client API, not a Bitwarden, Inc. product. Choose the official Bitwarden server when exact upstream behavior, formal support, or organizational compliance takes priority.</p>
<p>The current license is <a href="https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt" title="AGPL-3.0" target="_blank" rel="noopener noreferrer">AGPL-3.0</a>. Its official installation path supports a single application container through Docker, Podman, or Compose, normally behind an HTTPS reverse proxy. Mobile, desktop, browser, and CLI clients can all reach one stable VPS endpoint without opening a home network.</p>
<p>Treat the server as a high-value target. Disable open registration when it is unnecessary, protect the admin interface and token, enable MFA where appropriate, and keep the image current. Backups need the database, attachments, and configuration rather than a copy of the disposable container. The <a href="https://sa.net/blog/self-host-vaultwarden/" title="Vaultwarden Docker tutorial">Vaultwarden Docker tutorial</a> walks through registration lockdown, client synchronization, SQLite-safe backup, and an isolated restore test.</p>
<h2 id="2-paperless-ngx-searchable-document-storage">2. Paperless-ngx: searchable document storage<a href="#2-paperless-ngx-searchable-document-storage" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><a href="https://github.com/paperless-ngx/paperless-ngx" title="Paperless-ngx" target="_blank" rel="noopener noreferrer">Paperless-ngx</a> watches an inbox, runs OCR, applies metadata, and makes scanned paperwork searchable from different devices. That processing can continue on an always-on VPS while your laptop is offline.</p>
<p>The project uses <a href="https://github.com/paperless-ngx/paperless-ngx/blob/dev/LICENSE" title="GPL-3.0" target="_blank" rel="noopener noreferrer">GPL-3.0</a>. Its <a href="https://docs.paperless-ngx.com/setup/" title="official setup guide" target="_blank" rel="noopener noreferrer">official setup guide</a> recommends Docker Compose, with the web application, a Redis-compatible broker, and a database as distinct responsibilities. Current guidance recommends PostgreSQL for new installations. Tika and Gotenberg are optional additions for relevant Office document formats.</p>
<p>OCR and conversion create short CPU and memory bursts, while originals and indexes create lasting storage pressure. The harder issue is trust: Paperless-ngx states that documents are stored in clear text and should not run on an untrusted host. Use authenticated HTTPS, harden the host, restrict administrative access, and encrypt backup destinations. Decide whether the VPS operator, host configuration, and recovery copies are suitable before uploading identity, tax, medical, or legal records. A local server is the safer fit when that trust requirement cannot be met. The <a href="https://sa.net/blog/self-host-paperless-ngx/" title="Paperless-ngx Docker Compose guide">Paperless-ngx Docker Compose guide</a> works through PostgreSQL setup, a real OCR acceptance test, and an empty same-version restore.</p>
<h2 id="3-freshrss-an-always-on-feed-reader">3. FreshRSS: an always-on feed reader<a href="#3-freshrss-an-always-on-feed-reader" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><a href="https://github.com/FreshRSS/FreshRSS" title="FreshRSS" target="_blank" rel="noopener noreferrer">FreshRSS</a> keeps polling feeds after your personal devices go to sleep. It also synchronizes subscriptions, read state, and favorites across browsers and compatible mobile clients. Publishers that support WebSub can deliver updates directly.</p>
<p>FreshRSS is <a href="https://github.com/FreshRSS/FreshRSS/blob/edge/LICENSE.txt" title="AGPL-3.0" target="_blank" rel="noopener noreferrer">AGPL-3.0</a>, and the project maintains an <a href="https://github.com/FreshRSS/FreshRSS/tree/edge/Docker" title="official Docker deployment" target="_blank" rel="noopener noreferrer">official Docker deployment</a>. It is a PHP application with a choice of SQLite, PostgreSQL, MariaDB, or MySQL. Small private installs can keep the shape simple; larger ones can move the database out of the app container.</p>
<p>Feed count, refresh frequency, full-text scraping, and retention drive load. Schedule refresh jobs deliberately, watch slow or failed sources, and avoid hammering publishers. Database and configuration backups matter more than container backups. Use the <a href="https://sa.net/blog/self-host-freshrss/" title="FreshRSS Docker guide">FreshRSS Docker guide</a> to configure the private first-run path and refresh scheduler.</p>
<h2 id="4-forgejo-git-hosting-for-a-small-team">4. Forgejo: Git hosting for a small team<a href="#4-forgejo-git-hosting-for-a-small-team" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Webhooks need a reachable endpoint, and Git over SSH or HTTPS benefits from a stable hostname. <a href="https://codeberg.org/forgejo/forgejo" title="Forgejo" target="_blank" rel="noopener noreferrer">Forgejo</a> supplies both, alongside repositories, issues, pull requests, packages, and releases for a developer or small team.</p>
<p>The canonical license is <a href="https://codeberg.org/forgejo/forgejo/src/branch/forgejo/LICENSE" title="GPL-3.0-or-later" target="_blank" rel="noopener noreferrer">GPL-3.0-or-later</a>. Forgejo provides an <a href="https://forgejo.org/docs/latest/admin/installation/docker/" title="official container installation" target="_blank" rel="noopener noreferrer">official container installation</a> and can start with SQLite or use an external database. The server may be modest when traffic is low, but repository history, Git LFS objects, packages, release files, and Actions artifacts can fill NVMe storage much sooner than the web interface suggests.</p>
<p>Forgejo Actions runners are separate from the server. They execute workflow code and need their own isolation, patching, capacity, and trust policy. Do not size the forge as if runners were free background threads. Disable public registration unless you are prepared to handle spam and abuse. The <a href="https://sa.net/blog/self-host-forgejo/" title="Forgejo Docker guide">Forgejo Docker guide</a> keeps server deployment separate from runner trust decisions.</p>
<h2 id="5-umami-web-analytics-on-your-own-collector">5. Umami: web analytics on your own collector<a href="#5-umami-web-analytics-on-your-own-collector" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Every site measured by <a href="https://github.com/umami-software/umami" title="Umami" target="_blank" rel="noopener noreferrer">Umami</a> sends events to its collection endpoint. Hosting that endpoint on an always-on VPS gives several sites one reachable collector and one operator-controlled dashboard.</p>
<p>Umami has an <a href="https://github.com/umami-software/umami/blob/master/LICENSE" title="MIT license" target="_blank" rel="noopener noreferrer">MIT license</a>. Its <a href="https://docs.umami.is/docs/install" title="installation guide" target="_blank" rel="noopener noreferrer">installation guide</a> covers Docker Compose and prebuilt images, but this is not a single-container database-free service. PostgreSQL is required, and current documentation specifies PostgreSQL 12.14 or newer and recommends UTC.</p>
<p>Event volume, data retention, bot traffic, and report queries determine database growth and load. Set retention with storage in mind, back up PostgreSQL consistently, and monitor ingestion rather than judging capacity from dashboard activity. Self-hosting controls where the collector runs; it does not remove privacy, consent, or data-retention obligations. The <a href="https://sa.net/blog/self-host-umami/" title="Umami Docker deployment">Umami Docker deployment</a> pairs PostgreSQL backup and restore with a tested reverse-proxy path.</p>
<h2 id="6-changedetectionio-scheduled-page-monitoring">6. changedetection.io: scheduled page monitoring<a href="#6-changedetectionio-scheduled-page-monitoring" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Price, stock, policy, and content pages rarely change on your schedule. <a href="https://github.com/dgtlmoon/changedetection.io" title="changedetection.io" target="_blank" rel="noopener noreferrer">changedetection.io</a> checks them on its own schedule and can send results to browser extensions, notification services, or API integrations. A VPS supplies the continuous scheduler and outbound Internet access.</p>
<p>The project is <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/LICENSE" title="Apache-2.0" target="_blank" rel="noopener noreferrer">Apache-2.0</a>, with official <a href="https://github.com/dgtlmoon/changedetection.io#installation" title="Docker and Compose instructions" target="_blank" rel="noopener noreferrer">Docker and Compose instructions</a>. Basic HTML fetches are relatively simple. JavaScript-heavy targets may need WebDriver, Playwright, or the recommended browser service, which adds Chromium processes and much more variable CPU and memory use.</p>
<p>Network behavior is often the limiting factor. Target sites may rate-limit or block a data center IP, and more compute will not fix that. Set sensible intervals, respect target policies, and do not turn the service into an aggressive scraper. If optional AI summaries are enabled, monitored content is sent to the configured external AI provider, so review that data flow before using it on private pages. Start with the plain HTTP fetcher in the <a href="https://sa.net/blog/self-host-changedetection/" title="changedetection.io Docker Compose guide">changedetection.io Docker Compose guide</a> and add browser automation only when a target requires it.</p>
<h2 id="7-ntfy-http-push-notifications">7. ntfy: HTTP push notifications<a href="#7-ntfy-http-push-notifications" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A backup job can send one HTTP request to <a href="https://github.com/binwiederhier/ntfy" title="ntfy" target="_blank" rel="noopener noreferrer">ntfy</a>, and a subscribed phone or browser can receive the result. The same small interface works for cron, CI, monitoring, and home automation. Hosting it on a VPS makes it reachable across otherwise separate networks.</p>
<p>ntfy is dual-licensed under <a href="https://github.com/binwiederhier/ntfy/blob/main/LICENSE" title="Apache-2.0" target="_blank" rel="noopener noreferrer">Apache-2.0</a> and <a href="https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2" title="GPL-2.0" target="_blank" rel="noopener noreferrer">GPL-2.0</a>. The <a href="https://docs.ntfy.sh/install/" title="official self-hosting guide" target="_blank" rel="noopener noreferrer">official self-hosting guide</a> supports a compact Go server, with SQLite used by default for persistence.</p>
<p>The easy publish model can also be dangerously open. A guessable topic name is not access control. Configure authentication, deny-by-default permissions where appropriate, HTTPS, rate limits, and persistent volumes. Retained messages and attachments consume disk and monthly transfer. Default SQLite storage is single-writer, so it should not be presented as a horizontally scalable cluster. The <a href="https://sa.net/blog/self-host-ntfy/" title="ntfy Docker guide">ntfy Docker guide</a> starts from a closed access policy.</p>
<h2 id="8-searxng-private-metasearch-with-external-dependencies">8. SearXNG: private metasearch with external dependencies<a href="#8-searxng-private-metasearch-with-external-dependencies" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><a href="https://github.com/searxng/searxng" title="SearXNG" target="_blank" rel="noopener noreferrer">SearXNG</a> aggregates results from multiple search sources; it does not build an independent web index. A private VPS instance keeps the interface and its preferences available across your devices.</p>
<p>SearXNG uses <a href="https://github.com/searxng/searxng/blob/master/LICENSE" title="AGPL-3.0" target="_blank" rel="noopener noreferrer">AGPL-3.0</a>, and the project publishes an <a href="https://docs.searxng.org/admin/installation-docker.html" title="official container installation" target="_blank" rel="noopener noreferrer">official container installation</a>. Run it as an authenticated private service. An unrestricted domain invites bot traffic, raises the chance that upstream engines classify the VPS address as abusive, and may violate a provider’s acceptable-use rules.</p>
<p>Queries still go to upstream engines. Those services can return CAPTCHAs, throttle requests, or block data center IPs, so self-hosting does not remove the external dependency. The official <a href="https://docs.searxng.org/admin/searx.limiter.html" title="limiter" target="_blank" rel="noopener noreferrer">limiter</a> uses Valkey and helps control inbound bots, but it cannot guarantee acceptance by an upstream provider or replace access control. The private <a href="https://sa.net/blog/self-host-searxng/" title="SearXNG Docker Compose deployment">SearXNG Docker Compose deployment</a> puts Caddy authentication in front of Valkey-backed limiting and exact trusted-proxy handling.</p>
<h2 id="9-actual-private-budget-synchronization">9. Actual: private budget synchronization<a href="#9-actual-private-budget-synchronization" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><a href="https://github.com/actualbudget/actual" title="Actual" target="_blank" rel="noopener noreferrer">Actual</a> is local-first, but its server lets devices and users synchronize a budget without leaving one laptop online. End-to-end encryption is optional, requires a second password, and must be enabled separately for each budget. The VPS is a private sync endpoint, not a public finance portal or a business accounting system.</p>
<p>Actual has an <a href="https://github.com/actualbudget/actual/blob/master/LICENSE.txt" title="MIT license" target="_blank" rel="noopener noreferrer">MIT license</a> and an official <a href="https://actualbudget.org/docs/install/docker/" title="Docker installation path" target="_blank" rel="noopener noreferrer">Docker installation path</a>. Put the server behind a correctly configured reverse proxy with HTTPS, enable authentication and <a href="https://actualbudget.org/docs/getting-started/sync/" title="end-to-end encryption for each budget" target="_blank" rel="noopener noreferrer">end-to-end encryption for each budget</a>, keep it updated, and test backups. Exposing its raw application port is not a finished deployment.</p>
<p>Financial records increase the cost of a weak setup. Protect credentials, restrict access where practical, and verify that a restored server can reopen and synchronize the budget. Bank-sync tokens are stored separately from budget data and are not covered by Actual’s end-to-end encryption; a server administrator or hosting provider with database access can read them. If your only device already provides enough local-first functionality, the extra Internet-facing service may not be worth maintaining. In the <a href="https://sa.net/blog/self-host-actual-budget/" title="Actual Budget Docker Compose guide">Actual Budget Docker Compose guide</a>, server login, optional budget E2EE, two-client sync, and full-state recovery remain distinct tests.</p>
<h2 id="10-linkding-centralized-bookmarks">10. linkding: centralized bookmarks<a href="#10-linkding-centralized-bookmarks" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Bookmarks become more useful when the same searchable collection follows you between browsers and devices. <a href="https://github.com/sissbruecker/linkding" title="linkding" target="_blank" rel="noopener noreferrer">linkding</a> provides that endpoint for extensions, a bookmarklet, REST clients, and mobile apps. It is small enough to operate without turning bookmark capture into an infrastructure project.</p>
<p>The project is <a href="https://github.com/sissbruecker/linkding/blob/master/LICENSE.txt" title="MIT-licensed" target="_blank" rel="noopener noreferrer">MIT-licensed</a>. Its <a href="https://linkding.link/installation/" title="installation guide" target="_blank" rel="noopener noreferrer">installation guide</a> covers Docker, Compose, user creation, persistent storage, and reverse-proxy setup. SQLite is the default and PostgreSQL is available.</p>
<p>The basic deployment is straightforward, but the <code>latest-plus</code> image adds Chromium-based page archiving. That image is larger, browser processes use more memory, and saved snapshots make disk consumption grow with the collection. Back up the persistent database and archived assets rather than the replaceable container. If full archival is the primary job, a dedicated archive system may be a better fit than turning every bookmark into a snapshot. The <a href="https://sa.net/blog/self-host-linkding/" title="linkding Docker Compose guide">linkding Docker Compose guide</a> combines a private administrator bootstrap with application backups, isolated recovery, and tested egress blocks.</p>
<h2 id="why-some-popular-self-hosted-apps-are-absent">Why some popular self-hosted apps are absent<a href="#why-some-popular-self-hosted-apps-are-absent" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Home Assistant is usually most useful near local radios, sensors, and household devices. A VPS can host parts of the stack, but moving the primary controller away from the LAN adds dependence on the Internet path and complicates local hardware access.</p>
<p>Pi-hole and AdGuard Home are DNS resolvers first. Running one on a VPS can work only for clients reached through a deliberately restricted private-access design, but exposing an open recursive resolver is an abuse and amplification risk. A VPS-first recommendation needs complete access controls and resolver safeguards, not a public port 53 recipe.</p>
<p>Jellyfin and Immich are dominated by media storage, upload and download transfer, thumbnail or transcoding work, and sometimes GPU requirements. A storage-oriented home server or dedicated media host is often a better match than a general-purpose VPS with a monthly transfer quota.</p>
<p>Nextcloud can run well on a VPS, but it brings a larger operations footprint: database tuning, caching, background jobs, upgrades, file locking, storage growth, and recovery planning. It deserves a dedicated architecture guide rather than a casual place in a compact VPS-first list.</p>
<h2 id="shared-deployment-rules">Shared deployment rules<a href="#shared-deployment-rules" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Put each public web interface behind a maintained reverse proxy with valid TLS. Bind internal application and database ports to localhost or a private container network. Disable open registration unless it is intentional, use unique credentials, and enable MFA where the app supports it.</p>
<p>Container updates still require judgment. Pin stable versions or review release notes before automated updates, and keep a rollback path. Back up application data, databases, uploads, encryption material, and configuration. A running container does not prove that its data can be restored.</p>
<p>Watch disk growth in the places each app creates durable data: document originals, Git objects, analytics events, notification attachments, and page archives. Observe real CPU and memory use during OCR or browser automation before setting tight container limits. Apps that fetch arbitrary URLs also need outbound controls and protection against requests to internal metadata or private services.</p>
<h2 id="choosing-vps-resources-and-location">Choosing VPS resources and location<a href="#choosing-vps-resources-and-location" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Application count is a poor sizing metric. Workload shape, concurrency, retained data, and failure tolerance matter more. A few mostly idle private services behave differently from PostgreSQL analytics ingestion, document OCR, Chromium fetchers, or an Actions runner, even if both deployments contain the same number of containers.</p>
<p>For a few of the lighter services here, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable starting point. Add heavier jobs such as OCR, Chromium fetchers, or analytics ingestion and Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) buys useful headroom, while Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB) suits a fuller stack. Measure the heaviest jobs, watch how durable data grows, and keep an eye on monthly transfer if you host analytics collection, attachments, package hosting, or large Git operations.</p>
<p>Every plan includes daily backups, a 1 Gbps port, and full root access. Our VPS plans are unmanaged, so you run Linux, Docker, TLS, and the applications yourself and keep full control. Treat the daily backups as a safety net: pair them with application-consistent database dumps, off-server copies, and the occasional restore test.</p>
<p>Choose Tokyo or Singapore based on the people, websites, and upstream APIs that will use the service. If your users are in mainland China, it is worth a few minutes on the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from their access networks. We carry the three major mainland carriers over CTGNet (formerly China Telecom CN2 GIA, AS23764 / AS4809), China Unicom Premium (CUP, AS9929 / AS10099), and China Mobile International N2 (CMIN2, AS58807), and the best judge of any route is a test of the actual path at the hours that matter to you.</p>
<p>When you have a feel for the workload, compare it against <a href="https://sa.net/pricing/" title="Riven Cloud pricing">Riven Cloud pricing</a>. Start with the service whose failure you can recover from, confirm its backup restore, then add the next one. An app earns its place when its always-on benefit is worth its biggest caveat.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host Actual Budget with Docker]]></title>
        <id>https://sa.net/blog/self-host-actual-budget/</id>
        <link href="https://sa.net/blog/self-host-actual-budget/"/>
        <updated>2026-07-18T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy Actual Budget 26.7.0 with Docker Compose and Caddy TLS, sync two clients, and rehearse the restore before you trust it with your finances.]]></summary>
        <content type="html"><![CDATA[<p>Actual Budget is a local-first budgeting application with an optional sync server. The Docker Compose deployment is small: one server container and one persistent <code>/data</code> directory. No PostgreSQL, no Redis. The real work is elsewhere. You have to claim the uninitialized server before it reaches the Internet, keep the browser security headers intact, understand what the optional encryption does not cover, and prove that backups and client sync work.</p>
<p>This guide deploys Actual Budget <a href="https://github.com/actualbudget/actual/releases/tag/v26.7.0" title="26.7.0" target="_blank" rel="noopener noreferrer">26.7.0</a>, the current release when we checked on July 18, 2026, from the official <a href="https://hub.docker.com/r/actualbudget/actual-server/tags?name=26.7.0" title="Docker Hub image" target="_blank" rel="noopener noreferrer">Docker Hub image</a> on a Debian or Ubuntu VPS. Docker publishes port 5006 only on loopback. You set the server password through an SSH tunnel before Caddy exposes <code>budget.example.com</code>, then test a real budget from two clients.</p>
<h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need:</p>
<ul>
<li>a Debian or Ubuntu VPS with root or sudo access;</li>
<li>an A record for <code>budget.example.com</code> pointing to the VPS, plus an AAAA record only when IPv6 is configured and filtered correctly;</li>
<li>TCP 80 and 443 open for Caddy, along with your administrative SSH port;</li>
<li>a modern browser on two devices, or two isolated browser profiles;</li>
<li>encrypted storage outside the VPS for budget exports and server backups.</li>
</ul>
<p>First install Docker Engine and the Compose plugin. Docker’s convenience script is the fastest route; if running a downloaded script as root is against your policy, your distribution’s packages work too. Then add Caddy and UFW:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> bash</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> ufw</span></span></code></pre>
<p>Allow SSH before enabling the firewall. Replace port 22 if the host uses another administrative SSH port; our <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="guide to securing SSH on a VPS">guide to securing SSH on a VPS</a> covers moving the port and hardening logins without locking yourself out:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> verbose</span></span></code></pre>
<p>Mirror those inbound rules in any provider firewall. Do not open port 5006 publicly.</p>
<h2 id="create-the-actual-budget-compose-project">Create the Actual Budget Compose project<a href="#create-the-actual-budget-compose-project" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The <a href="https://actualbudget.org/docs/install/docker/" title="official Docker configuration" target="_blank" rel="noopener noreferrer">official Docker configuration</a> runs one <code>actual-server</code> service, mounts <code>/data</code>, and uses <code>node scripts/health-check.js</code> as its health check. The file below keeps that health check and adds a pinned version, a loopback-only host port, and log rotation.</p>
<p>As of the 26.7.0 image (checked July 18, 2026), the image creates an <code>actual</code> account with UID/GID 1001, but its OCI configuration does not select that account, so the container runs as root by default; a later image may change this, so recheck after upgrades. Create a root-owned data directory to match. Do not add <code>user: &quot;1001:1001&quot;</code> until you have proved that startup, writes, upgrades, backup, and restore all work under that override.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#9ECBFF"> /opt/actual</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#9ECBFF"> /opt/actual/actual-data</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/actual</span></span></code></pre>
<p>Create <code>/opt/actual/compose.yaml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> compose.yaml</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;YAML&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">services:</span></span>
<span class="line"><span style="color:#9ECBFF">  actual_server:</span></span>
<span class="line"><span style="color:#9ECBFF">    image: docker.io/actualbudget/actual-server:26.7.0</span></span>
<span class="line"><span style="color:#9ECBFF">    container_name: actual-server</span></span>
<span class="line"><span style="color:#9ECBFF">    restart: unless-stopped</span></span>
<span class="line"><span style="color:#9ECBFF">    ports:</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;127.0.0.1:5006:5006&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    volumes:</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./actual-data:/data</span></span>
<span class="line"><span style="color:#9ECBFF">    healthcheck:</span></span>
<span class="line"><span style="color:#9ECBFF">      test: [&quot;CMD-SHELL&quot;, &quot;node scripts/health-check.js&quot;]</span></span>
<span class="line"><span style="color:#9ECBFF">      interval: 60s</span></span>
<span class="line"><span style="color:#9ECBFF">      timeout: 10s</span></span>
<span class="line"><span style="color:#9ECBFF">      retries: 3</span></span>
<span class="line"><span style="color:#9ECBFF">      start_period: 20s</span></span>
<span class="line"><span style="color:#9ECBFF">    logging:</span></span>
<span class="line"><span style="color:#9ECBFF">      options:</span></span>
<span class="line"><span style="color:#9ECBFF">        max-size: &quot;10m&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">        max-file: &quot;3&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">YAML</span></span></code></pre>
<p><code>/data/server-files/account.sqlite</code> stores the hashed server password, sessions, and the server’s budget-file registry. Budget blobs live under <code>/data/user-files</code>. The <a href="https://actualbudget.org/docs/config/" title="server configuration reference" target="_blank" rel="noopener noreferrer">server configuration reference</a> documents those paths and the supported environment variables. Keeping the defaults puts the whole server state under one mount.</p>
<p>Start it and check the listener:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/actual</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:5006/health</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:5006&#39;</span></span></code></pre>
<p>The health endpoint should return <code>{&quot;status&quot;:&quot;UP&quot;}</code>, and the listener must show <code>127.0.0.1:5006</code>, not <code>0.0.0.0:5006</code> or <code>[::]:5006</code>.</p>
<h2 id="initialize-the-password-through-an-ssh-tunnel">Initialize the password through an SSH tunnel<a href="#initialize-the-password-through-an-ssh-tunnel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>An uninitialized Actual server asks the first visitor to set its server password. Do that privately before adding a public Caddy route.</p>
<p>From your workstation, open a tunnel and leave it running:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 5006:127.0.0.1:5006</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:5006</code>. Set a long, unique server password and store it in a password manager. This is the login password for the server, not an end-to-end encryption password.</p>
<p>Sign out, reload the page, and sign in again. Also try a wrong password. If the original setup screen reappears or the wrong password works, check the <code>actual-data</code> ownership and container logs before continuing.</p>
<p>The official <a href="https://actualbudget.org/docs/troubleshooting/reset_password/" title="password reset procedure" target="_blank" rel="noopener noreferrer">password reset procedure</a> runs a script inside the container, which means anyone with root or Docker access can reset the server authentication boundary. Limit administrative access to the VPS accordingly.</p>
<h2 id="add-caddy-without-replacing-actuals-security-headers">Add Caddy without replacing Actual’s security headers<a href="#add-caddy-without-replacing-actuals-security-headers" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Actual needs HTTPS and cross-origin isolation for the <code>SharedArrayBuffer</code> used by its browser-side SQLite engine. The server sets these response headers itself:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Cross-Origin-Opener-Policy: same-origin</span></span>
<span class="line"><span>Cross-Origin-Embedder-Policy: require-corp</span></span></code></pre>
<p>The <a href="https://actualbudget.org/docs/config/reverse-proxies/" title="official reverse-proxy guide" target="_blank" rel="noopener noreferrer">official reverse-proxy guide</a> warns that duplicate COOP or COEP headers can cause a fatal <code>SharedArrayBufferMissing</code> error. Caddy passes the upstream headers through, so use a plain reverse proxy and do not add another copy.</p>
<p>Create the site after the private password test has passed. Keep site snippets in a separate imported file so adding Actual does not erase other Caddy hosts; the conditional import appends the top-level import only when the main file lacks it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/actual.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">budget.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    encode zstd gzip</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:5006</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<p>Caddy obtains and renews the certificate once DNS and both firewalls allow TCP 80/443. Check the public path and the headers:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://budget.example.com/health</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> https://budget.example.com/</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> &#39;^cross-origin&#39;</span></span></code></pre>
<p>The header check should print exactly one <code>Cross-Origin-Opener-Policy: same-origin</code> and one <code>Cross-Origin-Embedder-Policy: require-corp</code>. Open the browser console and confirm there is no <code>SharedArrayBufferMissing</code> error.</p>
<h2 id="prove-budget-creation-and-two-client-sync">Prove budget creation and two-client sync<a href="#prove-budget-creation-and-two-client-sync" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A healthy endpoint proves that Node can answer a request. It does not prove that a budget can be written, downloaded, decrypted, or synchronized.</p>
<p>In the first browser:</p>
<ol>
<li>Sign in at <code>https://budget.example.com</code>.</li>
<li>Create a budget named <code>VPS restore test</code>.</li>
<li>Add an account named <code>Checking test</code> with an opening balance of 1,000.</li>
<li>Add a transaction for 12.34 with payee <code>Compose test</code> and a note containing today’s date.</li>
<li>Wait for the sync indicator to settle, then reload and confirm the balance and transaction remain.</li>
</ol>
<p>Open a private window, a separate browser profile, or a second device with no Actual local storage. Sign in to the same server, select the cloud budget, and let it download. Confirm the account, exact transaction amount, payee, note, and resulting balance.</p>
<p>Make a second change from that client, such as changing the note to <code>Second client sync works</code>. Wait for sync, return to the first client, and confirm the edit arrives. Avoid simultaneous edits to the same field during this acceptance test. Actual supports concurrent clients, but a deterministic test is easier to diagnose.</p>
<h2 id="decide-whether-to-enable-budget-encryption">Decide whether to enable budget encryption<a href="#decide-whether-to-enable-budget-encryption" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Actual’s <a href="https://actualbudget.org/docs/getting-started/sync/" title="sync documentation" target="_blank" rel="noopener noreferrer">sync documentation</a> describes optional end-to-end encryption per budget. Enabling it creates a second password that should differ from the server login password. The client encrypts budget data before upload, and each new device needs that second password once to derive the key.</p>
<p>Four boundaries matter:</p>
<ul>
<li>Encryption is optional and applies per budget, not automatically to every file on the server.</li>
<li>It cannot be switched off in place. Returning to an unencrypted file requires an export and import.</li>
<li>If every local copy is gone and the encryption password is forgotten, the budget cannot be recovered. A device that still has a local copy may reset sync and create a new key.</li>
<li>Local browser or desktop data remains unencrypted. Use full-disk encryption on every client device.</li>
</ul>
<p>If you enable encryption, open the budget settings, choose <strong>Enable encryption</strong>, save the second password separately, and repeat the clean second-client download. The second client must ask for the encryption password and then show the same transaction.</p>
<p>Bank sync has a different trust boundary. Actual’s <a href="https://actualbudget.org/docs/advanced/bank-sync/" title="bank-sync documentation" target="_blank" rel="noopener noreferrer">bank-sync documentation</a> states that API keys, secrets, and bank-sync tokens are stored on the server, outside budget E2EE. A server administrator, hosting provider, or attacker with direct database access can read them. Keep those credentials out of Compose files, shell history, screenshots, and tickets, and encrypt backups that may contain them. Actual also requires the user to trigger bank sync; this guide does not assume an unattended schedule.</p>
<h2 id="export-each-budget-and-back-up-all-of-data">Export each budget and back up all of <code>/data</code><a href="#export-each-budget-and-back-up-all-of-data" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Actual provides a portable application backup. Open each budget, go to its settings, choose <strong>Export Data</strong>, and save the Actual ZIP. The official <a href="https://actualbudget.org/docs/backup-restore/backup/" title="backup" target="_blank" rel="noopener noreferrer">backup</a> and <a href="https://actualbudget.org/docs/backup-restore/restore/" title="restore" target="_blank" rel="noopener noreferrer">restore</a> guides use that ZIP for import into a new budget. Export every budget separately and keep the old budget until the imported copy has been checked.</p>
<p>The ZIP is not the complete server. A stopped archive of <code>/data</code> also covers authentication, sessions, the server’s file registry, all budget blobs, and server-side bank-sync state. Take both.</p>
<p>Create a consistent archive with Actual stopped:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/actual</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /var/backups/actual</span></span>
<span class="line"><span style="color:#E1E4E8">archive</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;/var/backups/actual/actual-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> actual_server</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> --numeric-owner</span><span style="color:#79B8FF"> --xattrs</span><span style="color:#79B8FF"> --acls</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/actual</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -czf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> actual-data</span><span style="color:#9ECBFF"> compose.yaml</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span><span style="color:#9ECBFF"> actual_server</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sha256sum</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">.sha256&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">.sha256&quot;</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> --retry</span><span style="color:#79B8FF"> 12</span><span style="color:#79B8FF"> --retry-delay</span><span style="color:#79B8FF"> 5</span><span style="color:#79B8FF"> --retry-all-errors</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  http://127.0.0.1:5006/health</span></span></code></pre>
<p>Encrypt the archive and the ZIP exports, then copy them off the VPS. A backup that exists only on the same disk is not a disaster-recovery copy.</p>
<h2 id="restore-the-same-version-in-isolation">Restore the same version in isolation<a href="#restore-the-same-version-in-isolation" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Test the archive without touching production. The drill below verifies the checksum, extracts into a fresh directory, and runs a separate Compose project on loopback port 15006 with an internal Docker network that has no external route. That last control keeps the restored server from contacting real bank-sync providers.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#E1E4E8">BACKUP</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">ls</span><span style="color:#79B8FF"> -1</span><span style="color:#9ECBFF"> /var/backups/actual/actual-</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">.tar.gz</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> tail</span><span style="color:#79B8FF"> -n</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sha256sum</span><span style="color:#79B8FF"> -c</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">.sha256&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">restore_dir</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;/opt/actual-restore-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%Y%m%d%H%M%S)&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> --numeric-owner</span><span style="color:#79B8FF"> --xattrs</span><span style="color:#79B8FF"> --acls</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span></code></pre>
<p>Create <code>compose.restore.yaml</code> in that directory:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  actual_server</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">docker.io/actualbudget/actual-server:26.7.0</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:15006:5006&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./actual-data:/data</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;node scripts/health-check.js&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">20s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">      start_period</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">20s</span></span>
<span class="line"><span style="color:#85E89D">    networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">restore_only</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;no&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  restore_only</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    internal</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span></code></pre>
<p>Start only the isolated copy:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> actual-restore</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -f</span><span style="color:#9ECBFF"> compose.restore.yaml</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:15006/health</span></span></code></pre>
<p>Tunnel workstation port 15006 to the restored listener, sign in, open <code>VPS restore test</code>, and verify the known balance and transaction. If E2EE is enabled, supply its separate password. Do not click Bank Sync; the internal network should make any accidental provider connection fail, but restraint is still part of the test.</p>
<p>Also import one application ZIP as a new budget and compare accounts, balances, categories, and transactions. That proves both recovery paths. Tear down the isolated project after recording the result:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> actual-restore</span><span style="color:#79B8FF"> -f</span><span style="color:#9ECBFF"> compose.restore.yaml</span><span style="color:#9ECBFF"> down</span></span></code></pre>
<h2 id="upgrade-with-a-data-aware-rollback">Upgrade with a data-aware rollback<a href="#upgrade-with-a-data-aware-rollback" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the <a href="https://github.com/actualbudget/actual/releases" title="Actual release notes" target="_blank" rel="noopener noreferrer">Actual release notes</a> before changing versions. Export every budget, take a stopped full backup, and complete the isolated restore first. Then replace the version tag in <code>compose.yaml</code> with the reviewed target:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/actual</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> actual_server</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://budget.example.com/health</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://budget.example.com/info</span></span></code></pre>
<p>The info endpoint should report the new version. Repeat login, budget opening, a new transaction, and second-client sync, and check the COOP and COEP headers again. Actual may migrate budget or server data during an upgrade, so switching the image back is not a complete rollback. If the upgrade fails, stop the new container, keep the upgraded <code>/data</code> for diagnosis, and restore the old 26.7.0 image definition together with the pre-upgrade <code>/data</code> archive. Start that matched pair and rerun the acceptance test.</p>
<h2 id="choosing-a-riven-cloud-vps-for-actual-budget">Choosing a Riven Cloud VPS for Actual Budget<a href="#choosing-a-riven-cloud-vps-for-actual-budget" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Actual Budget is a light workload. For a household or small-team budget server, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is plenty, with room left over for a couple of other small containers. Step up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) or Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) if you plan to co-host heavier services (our <a href="https://sa.net/blog/best-self-hosted-apps/" title="best self-hosted apps guide">best self-hosted apps guide</a> has candidates worth pairing with a budget server) or want more space for years of budgets and backup archives on the same disk.</p>
<p>Every plan has a 1 Gbps port, full root access, and a choice of Tokyo or Singapore, so pick the location closest to the people who open and sync budgets. If you connect from mainland China, it is worth a minute to run the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own carrier before deciding. The <a href="https://sa.net/pricing/" title="Riven Cloud VPS pricing page">Riven Cloud VPS pricing page</a> has the current plan details.</p>
<p>Our plans are unmanaged, so you run the stack yourself and keep full control, and every plan includes daily backups on our side. Those cover whole-host recovery nicely; keep the per-budget ZIP exports, the stopped <code>/data</code> archive, and the restore drill above in your routine as well, since application-level recovery is the part only you can test.</p>
<h2 id="security-limits-and-non-fit-cases">Security limits and non-fit cases<a href="#security-limits-and-non-fit-cases" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep port 5006 on loopback, patch the host and container, restrict Docker access, and monitor failed logins and disk usage. Use unique server and E2EE passwords. Treat bank tokens and every full backup as financial secrets. Do not co-host Actual with untrusted containers that get the Docker socket or broad access to <code>/opt/actual</code>.</p>
<p>Self-hosting Actual works best when someone clearly owns TLS, updates, monitoring, encrypted off-server backups, and restore tests. If your situation calls for managed key custody, audited access controls, high availability, or a contractual SLA, a hosted service with the right bank-integration model will serve you better; optional budget E2EE does not make bank credentials unreadable to the server operator, so it cannot stand in for that kind of control.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host SearXNG with Docker]]></title>
        <id>https://sa.net/blog/self-host-searxng/</id>
        <link href="https://sa.net/blog/self-host-searxng/"/>
        <updated>2026-07-18T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy a private SearXNG instance with Docker Compose behind Caddy Basic auth, then make sure the limiter, backups, and upstream engines all behave.]]></summary>
        <content type="html"><![CDATA[<p>SearXNG aggregates results from several search services without passing each user’s browser cookies or home address to those services, which makes it a popular thing to run on a small VPS. One thing to settle up front: keep your instance private and authenticated. Our <a href="https://sa.net/aup/" title="Acceptable Use Policy">Acceptable Use Policy</a> does not allow public Tor, open proxy, or anonymous proxy services, and in practice an open search endpoint attracts abuse traffic fast. The setup below puts Caddy in front, so every search requires a username and password.</p>
<p>The stack follows the official core plus Valkey architecture and pins the <a href="https://hub.docker.com/r/searxng/searxng/tags?name=2026.7.13-9e25585ae" title="official SearXNG image tag" target="_blank" rel="noopener noreferrer">official SearXNG image tag</a> <code>2026.7.13-9e25585ae</code>. Caddy adds TLS and bcrypt-backed Basic authentication. Only Caddy publishes host ports; SearXNG port 8080 and Valkey port 6379 stay inside a fixed Docker subnet.</p>
<p>The limiter needs accurate client addresses and Valkey state, but it only shapes request behavior after authentication; deciding who may use the service stays Caddy’s job.</p>
<h2 id="prepare-the-vps-and-dns">Prepare the VPS and DNS<a href="#prepare-the-vps-and-dns" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need a Debian or Ubuntu VPS with root or sudo access, an A record for <code>search.example.com</code>, and an AAAA record only when IPv6 works end to end. TCP 80 and 443 must reach the VPS for Caddy. Keep the administrative SSH port open, and leave 8080 and 6379 closed in UFW, security groups, and the provider firewall; only Caddy needs to be reachable from outside.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script (inspect it first if that worries you; on a tightly managed host, use your distribution’s packages instead), then confirm the domain resolves:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> bash</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span>
<span class="line"><span style="color:#B392F0">getent</span><span style="color:#9ECBFF"> ahosts</span><span style="color:#9ECBFF"> search.example.com</span></span></code></pre>
<p>The <code>sudo docker compose version</code> check above is the only privileged docker call in this guide; the later docker commands run unprivileged, so add your user to the docker group (<code>sudo usermod -aG docker $USER</code>, then log out and back in) or run them with sudo yourself.</p>
<p>If UFW is active, retain its working SSH rule and add only the public web ports:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span></code></pre>
<p>The old <code>searxng/searxng-docker</code> repository is archived, so skip it for a new deployment. As of July 18, 2026, the maintained <a href="https://github.com/searxng/searxng/tree/master/container" title="container template lives in the SearXNG core repository" target="_blank" rel="noopener noreferrer">container template lives in the SearXNG core repository</a> and defines <code>core</code> plus <code>valkey</code>.</p>
<h2 id="generate-the-configuration-and-secret">Generate the configuration and secret<a href="#generate-the-configuration-and-secret" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create a private project directory, an application secret, and a Caddy password hash. SearXNG’s entrypoint generates a random secret when it creates a missing <code>settings.yml</code>; because this setup supplies its own settings file, it sets <code>SEARXNG_SECRET</code> explicitly. The Caddy password lives only in a transient shell variable, and <code>.env</code> stores the bcrypt hash rather than the plaintext.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> core-config</span></span>
<span class="line"><span style="color:#79B8FF">read</span><span style="color:#79B8FF"> -rp</span><span style="color:#9ECBFF"> &#39;Private SearXNG username: &#39;</span><span style="color:#9ECBFF"> SEARCH_USER</span></span>
<span class="line"><span style="color:#F97583">case</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> in</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;&#39;</span><span style="color:#F97583">|*</span><span style="color:#DBEDFF">[!A-Za-z0-9._-]</span><span style="color:#F97583">*</span><span style="color:#F97583">)</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &#39;Use only letters, digits, dot, underscore, or hyphen&#39;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#E1E4E8">    ;;</span></span>
<span class="line"><span style="color:#F97583">esac</span></span>
<span class="line"><span style="color:#79B8FF">read</span><span style="color:#79B8FF"> -rsp</span><span style="color:#9ECBFF"> &#39;Private SearXNG password: &#39;</span><span style="color:#9ECBFF"> SEARCH_PASSWORD</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;\n&#39;</span></span>
<span class="line"><span style="color:#E1E4E8">SEARCH_PASSWORD_HASH</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  caddy:2-alpine</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  caddy</span><span style="color:#9ECBFF"> hash-password</span><span style="color:#79B8FF"> --algorithm</span><span style="color:#9ECBFF"> bcrypt</span><span style="color:#79B8FF"> --plaintext</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_PASSWORD</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#79B8FF">unset</span><span style="color:#9ECBFF"> SEARCH_PASSWORD</span></span>
<span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;SEARXNG_SECRET=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-hex</span><span style="color:#79B8FF"> 32</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;SEARCH_USER=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_USER</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &quot;SEARCH_PASSWORD_HASH=&#39;%s&#39;\n&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_PASSWORD_HASH</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;%s\n&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_BASE_URL=https://search.example.com/&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_BIND_ADDRESS=0.0.0.0&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_PORT=8080&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_LIMITER=true&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_PUBLIC_INSTANCE=false&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_IMAGE_PROXY=true&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;SEARXNG_VALKEY_URL=valkey://valkey:6379/0&#39;</span></span>
<span class="line"><span style="color:#E1E4E8">} </span><span style="color:#F97583">&gt;</span><span style="color:#E1E4E8"> .env</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> .env</span></span>
<span class="line"><span style="color:#79B8FF">unset</span><span style="color:#9ECBFF"> SEARCH_USER</span><span style="color:#9ECBFF"> SEARCH_PASSWORD_HASH</span></span></code></pre>
<p>Keep the secret and bcrypt hash unchanged across ordinary restarts and restores, and keep <code>.env</code> out of tickets, shell transcripts, and repositories. SearXNG’s <a href="https://docs.searxng.org/admin/settings/settings_server.html" title="server settings reference" target="_blank" rel="noopener noreferrer">server settings reference</a> documents the server overrides. <code>SEARXNG_PUBLIC_INSTANCE=false</code> matches this authenticated deployment; access control comes from Caddy, not that flag.</p>
<p>Create <code>/opt/searxng/core-config/settings.yml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">use_default_settings</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span></code></pre>
<p>Create <code>/opt/searxng/core-config/limiter.toml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml"><code><span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">botdetection</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">ipv4_prefix = </span><span style="color:#79B8FF">32</span></span>
<span class="line"><span style="color:#E1E4E8">ipv6_prefix = </span><span style="color:#79B8FF">48</span></span>
<span class="line"><span style="color:#E1E4E8">trusted_proxies = [</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;172.30.0.2/32&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">botdetection</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">ip_limit</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">filter_link_local = </span><span style="color:#79B8FF">false</span></span>
<span class="line"><span style="color:#E1E4E8">link_token = </span><span style="color:#79B8FF">true</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">botdetection</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">ip_lists</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">block_ip = []</span></span>
<span class="line"><span style="color:#E1E4E8">pass_ip = []</span></span>
<span class="line"><span style="color:#E1E4E8">pass_searxng_org = </span><span style="color:#79B8FF">true</span></span></code></pre>
<p>The <a href="https://docs.searxng.org/admin/searx.limiter.html" title="limiter documentation" target="_blank" rel="noopener noreferrer">limiter documentation</a> explains how SearXNG walks forwarded addresses past trusted proxies. The <code>/32</code> trusts only Caddy’s fixed address inside the private <code>172.30.0.0/24</code> proxy subnet. Trusting every RFC1918 range would allow another container or an accidentally exposed proxy to supply a forged client address. Add a <code>pass_ip</code> only for a monitored source you control; passed addresses also skip other bot checks.</p>
<h2 id="create-the-core-valkey-and-caddy-services">Create the core, Valkey, and Caddy services<a href="#create-the-core-valkey-and-caddy-services" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create <code>/opt/searxng/Caddyfile</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>search.example.com {</span></span>
<span class="line"><span>    encode zstd gzip</span></span>
<span class="line"><span>    basic_auth {</span></span>
<span class="line"><span>        {$SEARCH_USER} {$SEARCH_PASSWORD_HASH}</span></span>
<span class="line"><span>    }</span></span>
<span class="line"><span>    reverse_proxy core:8080</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Caddy is not part of the official SearXNG Compose template; this integration relies on its documented <a href="https://caddyserver.com/docs/automatic-https" title="automatic HTTPS" target="_blank" rel="noopener noreferrer">automatic HTTPS</a> and <a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy" title="reverse proxy" target="_blank" rel="noopener noreferrer">reverse proxy</a> behavior. Caddy sets the forwarded host, scheme, and client chain and ignores spoofed incoming forwarded values by default.</p>
<p>Create <code>/opt/searxng/compose.yml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  caddy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">caddy:2-alpine</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;80:80&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;443:443&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./Caddyfile:/etc/caddy/Caddyfile:ro</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">caddy-data:/data</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">caddy-config:/config</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      SEARCH_USER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${SEARCH_USER}</span></span>
<span class="line"><span style="color:#85E89D">      SEARCH_PASSWORD_HASH</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${SEARCH_PASSWORD_HASH}</span></span>
<span class="line"><span style="color:#85E89D">    networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      proxy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        ipv4_address</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">172.30.0.2</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  core</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">searxng/searxng:2026.7.13-9e25585ae</span></span>
<span class="line"><span style="color:#85E89D">    env_file</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./.env</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./core-config:/etc/searxng</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">core-data:/var/cache/searxng</span></span>
<span class="line"><span style="color:#85E89D">    networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      proxy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        ipv4_address</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">172.30.0.3</span></span>
<span class="line"><span style="color:#85E89D">    depends_on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      valkey</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        condition</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">service_healthy</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  valkey</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">valkey/valkey:9-alpine</span></span>
<span class="line"><span style="color:#85E89D">    command</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">valkey-server --save 30 1 --loglevel warning</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">valkey-data:/data</span></span>
<span class="line"><span style="color:#85E89D">    networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      proxy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        ipv4_address</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">172.30.0.4</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;valkey-cli&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;ping&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  proxy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    ipam</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      config</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">        - </span><span style="color:#85E89D">subnet</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">172.30.0.0/24</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  core-data</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  valkey-data</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  caddy-data</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  caddy-config</span><span style="color:#E1E4E8">:</span></span></code></pre>
<p>The SearXNG service has no <code>ports</code> entry. Valkey has neither <code>ports</code> nor a password because only containers on this project network can reach it; publishing 6379 would break that boundary. The <code>core</code> service loads the whole mode-600 <code>.env</code> file through <code>env_file</code>, which is where it gets <code>SEARXNG_SECRET</code>. Caddy deliberately does not: its <code>environment</code> block passes only the username and bcrypt hash, which Compose interpolates from the same <code>.env</code> in the project directory, so the application secret never enters the Caddy container. Basic credentials are safe in transit only over TLS, so do not publish an HTTP-only route.</p>
<p>Start the deployment and inspect all three services:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> config</span><span style="color:#79B8FF"> --quiet</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> ps</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=100</span><span style="color:#9ECBFF"> core</span><span style="color:#9ECBFF"> valkey</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<h2 id="test-valkey-tls-search-and-the-limiter">Test Valkey, TLS, search, and the limiter<a href="#test-valkey-tls-search-and-the-limiter" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the credentials, then check the state service and the authenticated HTTPS path:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#E1E4E8">SEARCH_USER</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">sed</span><span style="color:#79B8FF"> -n</span><span style="color:#9ECBFF"> &#39;s/^SEARCH_USER=//p&#39;</span><span style="color:#9ECBFF"> .env</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#79B8FF">read</span><span style="color:#79B8FF"> -rsp</span><span style="color:#9ECBFF"> &#39;Private SearXNG password: &#39;</span><span style="color:#9ECBFF"> SEARCH_PASSWORD</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;\n&#39;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> valkey</span><span style="color:#9ECBFF"> valkey-cli</span><span style="color:#9ECBFF"> ping</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#79B8FF"> -u</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_USER</span><span style="color:#9ECBFF">:</span><span style="color:#E1E4E8">$SEARCH_PASSWORD</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> https://search.example.com/</span></span></code></pre>
<p>Valkey must return <code>PONG</code> and the authenticated request must succeed. Next, prove that an unauthenticated request is refused and that Docker has not opened the private ports on the host:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;%{http_code}\n&#39;</span><span style="color:#9ECBFF"> https://search.example.com/</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#79B8FF"> -E</span><span style="color:#9ECBFF"> &#39;:(8080|6379)\b&#39;</span></span></code></pre>
<p>The first command must print <code>401</code>; the second must print nothing.</p>
<p>The default output format is HTML, so test with a real search rather than a JSON endpoint. Send browser-like headers and require at least one rendered result:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -u</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_USER</span><span style="color:#9ECBFF">:</span><span style="color:#E1E4E8">$SEARCH_PASSWORD</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -A</span><span style="color:#9ECBFF"> &#39;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/126 Safari/537.36&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Accept-Language: en-US,en;q=0.8&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Accept-Encoding: gzip, deflate, br&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --compressed</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data-urlencode</span><span style="color:#9ECBFF"> &#39;q=SearXNG metasearch&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://search.example.com/search</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -o</span><span style="color:#9ECBFF"> /tmp/searxng-search.html</span></span>
<span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -q</span><span style="color:#9ECBFF"> &#39;&lt;article class=&quot;result &#39;</span><span style="color:#9ECBFF"> /tmp/searxng-search.html</span></span>
<span class="line"><span style="color:#B392F0">rm</span><span style="color:#9ECBFF"> /tmp/searxng-search.html</span></span></code></pre>
<p>Repeat the search in a real browser and open one result. If the page fills with engine errors, the search did not really succeed; the error messages and logs will tell you why.</p>
<p>Now send an intentionally bot-like burst. The limiter checks more than request counts, including request headers and user-agent behavior, so exact rejection timing depends on the current <code>limiter.toml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> request </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 20</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#B392F0">  curl</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;%{http_code}\n&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">    -u</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$SEARCH_USER</span><span style="color:#9ECBFF">:</span><span style="color:#E1E4E8">$SEARCH_PASSWORD</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">    -A</span><span style="color:#9ECBFF"> &#39;curl/limiter-test&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;https://search.example.com/search?q=limiter-test&#39;</span></span>
<span class="line"><span style="color:#F97583">done</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> uniq</span><span style="color:#79B8FF"> -c</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --since=10m</span><span style="color:#9ECBFF"> core</span><span style="color:#9ECBFF"> valkey</span><span style="color:#9ECBFF"> caddy</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">  grep</span><span style="color:#79B8FF"> -Ei</span><span style="color:#9ECBFF"> &#39;limiter|bot|429|403|valkey|error&#39;</span><span style="color:#F97583"> ||</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#79B8FF">unset</span><span style="color:#9ECBFF"> SEARCH_PASSWORD</span></span></code></pre>
<p>The burst should produce blocked responses, commonly 403 or 429, while the browser-like search stays usable. If every request appears to come from <code>172.30.0.2</code>, stop and fix <code>trusted_proxies</code>; raising limits would only hide the proxy error. Check the logs for Valkey connection failures. The limiter is not access control: it can throttle an accepted request, but only Caddy’s authentication decides whether a request reaches SearXNG.</p>
<h2 id="understand-the-privacy-boundary">Understand the privacy boundary<a href="#understand-the-privacy-boundary" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>SearXNG’s <a href="https://docs.searxng.org/own-instance.html" title="own-instance documentation" target="_blank" rel="noopener noreferrer">own-instance documentation</a> explains that it does not forward a user’s cookies to search engines and presents generated request profiles. The selected upstream engines still receive the search terms because they need the query to return results. They normally see the VPS exit IP instead of the user’s IP.</p>
<p>A private instance used by one person can still be linkable through its stable exit address, timing, and query content. Multiple authenticated users send more queries from one data-center IP and may trigger upstream rate limits, CAPTCHA challenges, or an IP block; SearXNG cannot promise that every engine stays available. And to repeat the house rule from the top: please keep Tor, relay, and open-proxy functionality off this deployment, as our AUP does not allow them.</p>
<h2 id="back-up-configuration-and-both-data-volumes">Back up configuration and both data volumes<a href="#back-up-configuration-and-both-data-volumes" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>SearXNG has no application-level backup tool. This procedure follows its documented persistent paths and Docker’s <a href="https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes" title="volume backup guidance" target="_blank" rel="noopener noreferrer">volume backup guidance</a>. It stops the whole project so the core cache and the Valkey snapshot belong to one recovery point:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#E1E4E8">core_volume</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> inspect</span><span style="color:#79B8FF"> -f</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;{{range .Mounts}}{{if eq .Destination &quot;/var/cache/searxng&quot;}}{{.Name}}{{end}}{{end}}&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose ps </span><span style="color:#79B8FF">-q</span><span style="color:#9ECBFF"> core)&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">valkey_volume</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> inspect</span><span style="color:#79B8FF"> -f</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;{{range .Mounts}}{{if eq .Destination &quot;/data&quot;}}{{.Name}}{{end}}{{end}}&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose ps </span><span style="color:#79B8FF">-q</span><span style="color:#9ECBFF"> valkey)&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S</span><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;backups/searxng-config-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.tar.gz&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  compose.yml</span><span style="color:#9ECBFF"> Caddyfile</span><span style="color:#9ECBFF"> .env</span><span style="color:#9ECBFF"> core-config</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$core_volume</span><span style="color:#9ECBFF">:/source:ro&quot;</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$PWD</span><span style="color:#9ECBFF">/backups:/backup&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /source</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;/backup/searxng-core-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.tar.gz&quot;</span><span style="color:#9ECBFF"> .</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$valkey_volume</span><span style="color:#9ECBFF">:/source:ro&quot;</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$PWD</span><span style="color:#9ECBFF">/backups:/backup&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /source</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;/backup/searxng-valkey-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.tar.gz&quot;</span><span style="color:#9ECBFF"> .</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;:&quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> backups/</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF">*</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> backups/</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF">*</span></span>
<span class="line"><span style="color:#B392F0">sha256sum</span><span style="color:#9ECBFF"> backups/</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">&quot;.tar.gz</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> &quot;backups/searxng-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.sha256&quot;</span></span></code></pre>
<p>The configuration archive contains <code>SEARXNG_SECRET</code>, so encrypt all three archives and copy them off the VPS. Back up Caddy’s <code>caddy-data</code> volume too if you want to preserve its certificate state, and steer clear of <code>docker compose down -v</code> around these volumes; the <code>-v</code> flag deletes them.</p>
<h2 id="rehearse-an-isolated-same-version-restore">Rehearse an isolated same-version restore<a href="#rehearse-an-isolated-same-version-restore" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Use a disposable VPS when possible. The same-host drill below uses timestamped names for its directory, network, containers, and volumes, never joins the production Caddy, and starts the same image versions that produced the backup. Keep the commands and the later cleanup in one shell.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#E1E4E8">config_archive</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">ls</span><span style="color:#79B8FF"> -1</span><span style="color:#9ECBFF"> /opt/searxng/backups/searxng-config-</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">.tar.gz</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> tail</span><span style="color:#79B8FF"> -n</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">backup_stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">basename</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$config_archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> .tar.gz</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">backup_stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">${backup_stamp</span><span style="color:#F97583">#</span><span style="color:#E1E4E8">searxng-config-}</span></span>
<span class="line"><span style="color:#B392F0">sha256sum</span><span style="color:#79B8FF"> -c</span><span style="color:#9ECBFF"> &quot;backups/searxng-</span><span style="color:#E1E4E8">$backup_stamp</span><span style="color:#9ECBFF">.sha256&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">restore_id</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%Y%m%d%H%M%S)&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">restore_dir</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;/opt/searxng-restore-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">tar</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$config_archive</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> network</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;searxng-restore-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> volume</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> volume</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/restore&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> /opt/searxng/backups:/backup:ro</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /restore</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;/backup/searxng-core-</span><span style="color:#E1E4E8">$backup_stamp</span><span style="color:#9ECBFF">.tar.gz&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> -v</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/restore&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> /opt/searxng/backups:/backup:ro</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /restore</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;/backup/searxng-valkey-</span><span style="color:#E1E4E8">$backup_stamp</span><span style="color:#9ECBFF">.tar.gz&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --name</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --network</span><span style="color:#9ECBFF"> &quot;searxng-restore-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> --network-alias</span><span style="color:#9ECBFF"> valkey</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/data&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  valkey/valkey:9-alpine</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  valkey-server</span><span style="color:#79B8FF"> --save</span><span style="color:#79B8FF"> 30</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> --loglevel</span><span style="color:#9ECBFF"> warning</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --name</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --network</span><span style="color:#9ECBFF"> &quot;searxng-restore-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --env-file</span><span style="color:#9ECBFF"> .env</span><span style="color:#79B8FF"> -e</span><span style="color:#9ECBFF"> SEARXNG_BASE_URL=http://127.0.0.1:18080/</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -p</span><span style="color:#9ECBFF"> 127.0.0.1:18080:8080</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$PWD</span><span style="color:#9ECBFF">/core-config:/etc/searxng&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/var/cache/searxng&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  searxng/searxng:2026.7.13-9e25585ae</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span></code></pre>
<p>Tunnel port 18080, open the restored instance, and run a real search. Verify customized engines and cached data. The drill omits Caddy on purpose, so it proves data and basic function recovery only; it cannot validate Basic authentication, the trusted-proxy chain, or per-client limiter behavior. Remove the test resources when finished:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> rm</span><span style="color:#79B8FF"> -f</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> volume</span><span style="color:#9ECBFF"> rm</span><span style="color:#9ECBFF"> &quot;searxng-restore-core-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;searxng-restore-valkey-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> network</span><span style="color:#9ECBFF"> rm</span><span style="color:#9ECBFF"> &quot;searxng-restore-</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> rm</span><span style="color:#79B8FF"> -r</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span></code></pre>
<h2 id="update-images-and-templates-separately">Update images and templates separately<a href="#update-images-and-templates-separately" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For an image update, back up and restore-test first, review the upstream commit, and bump the SearXNG tag. Change Valkey or Caddy only when you mean to update them. Then redeploy:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/searxng</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> config</span><span style="color:#79B8FF"> --quiet</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=100</span><span style="color:#9ECBFF"> core</span><span style="color:#9ECBFF"> valkey</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<p>Repeat the Valkey, TLS, browser-like search, and limiter checks. Keep the previous image set and backup together; a safe rollback restores both rather than assuming newer data is backward compatible.</p>
<p>Template maintenance is a separate review. SearXNG’s official instructions may change service names, environment defaults, mounts, or Valkey options without changing your pinned image. Compare the current <code>container/docker-compose.yml</code>, <code>.env.example</code>, <code>settings.template.yml</code>, and <code>searx/limiter.toml</code> in the core repository with your local files. Avoid overwriting <code>.env</code>, <code>settings.yml</code>, or <code>limiter.toml</code> blindly, because that can replace the secret or discard your local proxy controls.</p>
<h2 id="choosing-a-riven-cloud-vps-for-searxng">Choosing a Riven Cloud VPS for SearXNG<a href="#choosing-a-riven-cloud-vps-for-searxng" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a personal or small-team instance, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) runs this whole stack comfortably. All plans come with a 1 Gbps port and full root access; our VPS are unmanaged, so you run the stack yourself and keep full control over it. If you enable many engines, serve several authenticated users, or proxy a lot of images, Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) gives the cache and transfer more headroom.</p>
<p>We offer Tokyo and Singapore. Pick the location that reaches both you and your upstream engines well. If you are on a mainland China network, test the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own carrier at the hours you actually search; the two cities can behave quite differently. Current specs and prices are on the <a href="https://sa.net/pricing/" title="Riven Cloud VPS plans">Riven Cloud VPS plans</a> page.</p>
<p>Every plan includes daily provider-side backups, and they make a good safety net. They can catch Valkey and the other volumes at different write points, though, so keep the stopped archives, the protected secret, and the encrypted off-server copies above as your primary recovery path.</p>
<h2 id="security-limits-and-non-fit-cases">Security limits and non-fit cases<a href="#security-limits-and-non-fit-cases" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>SearXNG has no built-in login of its own, which is why this deployment leans on Caddy Basic authentication. Test the logged-out denial as well as authenticated assets and form posts, keep the limiter enabled, patch all three images, watch logs and transfer, and keep Valkey off the public Internet. And please leave the Basic auth in place: an unauthenticated instance is open to the whole Internet, draws abuse traffic quickly, and runs against our AUP. The limiter slows bots down, but it is not a substitute for authentication.</p>
<p>Self-hosting is not the right fit for every case, and that is worth being honest about. If you need contractual availability, abuse handling, stable engine coverage, or a guaranteed clean exit reputation, a managed search product will serve you better. The same goes when upstream CAPTCHAs and IP blocks are unacceptable, when users need strong anonymity against traffic correlation, or when nobody has time to keep the limiter rules and restore drills current. SearXNG improves what upstream engines learn about you; it does not make searches invisible to them.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host changedetection.io with Docker]]></title>
        <id>https://sa.net/blog/self-host-changedetection/</id>
        <link href="https://sa.net/blog/self-host-changedetection/"/>
        <updated>2026-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy changedetection.io 0.55.8 with Docker Compose and Caddy TLS, set the password before the domain goes live, and prove your backups restore.]]></summary>
        <content type="html"><![CDATA[<p>changedetection.io monitors web pages and records what changed between fetches: price drops, restocks, quiet edits to a page you care about. The safe way to run it on a public domain is to start with the plain HTTP fetcher, keep port 5000 on loopback, and set the application password before the domain goes live. A browser fetcher can come later if a specific site needs one; adding it on day one only hands untrusted JavaScript a larger container and network surface without helping ordinary HTML pages.</p>
<p>The deployment below runs <a href="https://github.com/dgtlmoon/changedetection.io/releases/tag/0.55.8" title="changedetection.io 0.55.8" target="_blank" rel="noopener noreferrer">changedetection.io 0.55.8</a> on a Debian or Ubuntu VPS. Caddy terminates HTTPS at <code>change.example.com</code>; the application itself listens only on <code>127.0.0.1:5000</code>.</p>
<p>The install itself is only a few files. The ongoing work, and where this guide spends most of its time, is deciding what the service may reach and making sure its data can actually be restored. A good rule of thumb: use the smallest fetcher that can monitor the target, and remember that every watch URL is a network request your server makes on someone’s behalf.</p>
<h2 id="prepare-the-vps-dns-and-firewall">Prepare the VPS, DNS, and firewall<a href="#prepare-the-vps-dns-and-firewall" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need a VPS with root or sudo access, an A record for <code>change.example.com</code>, an AAAA record only when IPv6 works end to end, and encrypted backup storage outside the VPS. TCP 80 and 443 must reach Caddy. Keep your <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="administrative SSH port">administrative SSH port</a> open, and leave TCP 5000 closed in UFW and the provider firewall; Caddy reaches the application over loopback.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script (inspect it first if that worries you; on a tightly managed host, use your distribution’s packages instead):</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> bash</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> jq</span><span style="color:#9ECBFF"> ufw</span></span></code></pre>
<p>That version check aside, the rest of this guide runs docker without sudo, so either add your user to the docker group (<code>sudo usermod -aG docker $USER</code>, then log out and back in) or prefix the docker commands with sudo.</p>
<p>This firewall example assumes SSH listens on port 22. Change that rule before enabling UFW if your server uses another port.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> verbose</span></span></code></pre>
<p>Apply the same inbound policy at the hosting-provider firewall, and confirm the domain resolves to this server with <code>getent ahosts change.example.com</code>.</p>
<h2 id="create-the-docker-compose-project">Create the Docker Compose project<a href="#create-the-docker-compose-project" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The <a href="https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml" title="official 0.55.8 Compose file" target="_blank" rel="noopener noreferrer">official 0.55.8 Compose file</a> uses one application container, a named volume at <code>/datastore</code>, and the correct loopback port binding. Keep that architecture for the baseline deployment.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span></code></pre>
<p>Create <code>/opt/changedetection/compose.yml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  changedetection</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ghcr.io/dgtlmoon/changedetection.io:0.55.8</span></span>
<span class="line"><span style="color:#85E89D">    container_name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">changedetection</span></span>
<span class="line"><span style="color:#85E89D">    hostname</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">changedetection</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      BASE_URL</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;https://change.example.com&quot;</span></span>
<span class="line"><span style="color:#85E89D">      USE_X_SETTINGS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;1&quot;</span></span>
<span class="line"><span style="color:#85E89D">      LLM_FEATURES_DISABLED</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;true&quot;</span></span>
<span class="line"><span style="color:#85E89D">      HIDE_REFERER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;true&quot;</span></span>
<span class="line"><span style="color:#85E89D">      TZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;UTC&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">changedetection-data:/datastore</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:5000:5000&quot;</span></span>
<span class="line"><span style="color:#85E89D">    logging</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      options</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        max-size</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;10m&quot;</span></span>
<span class="line"><span style="color:#85E89D">        max-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3&quot;</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  changedetection-data</span><span style="color:#E1E4E8">:</span></span></code></pre>
<p><code>BASE_URL</code> gives notifications the public HTTPS origin. <code>USE_X_SETTINGS=1</code> lets the application honor proxy host and scheme headers. That setting is safe here because Docker accepts port 5000 only from the VPS loopback path and Caddy controls the forwarded headers. Do not combine it with a public <code>0.0.0.0:5000</code> binding.</p>
<p><code>LLM_FEATURES_DISABLED=true</code> prevents watched content and diffs from being sent to an LLM provider through the application’s AI features. <code>HIDE_REFERER=true</code> applies a referrer policy so monitored sites do not learn the changedetection.io hostname when an operator follows a link. Neither setting makes stored watch data public-safe.</p>
<p>Validate and start the service:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> config</span><span style="color:#79B8FF"> --quiet</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> changedetection</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:5000&#39;</span></span></code></pre>
<p>The listener must show <code>127.0.0.1:5000</code>, never <code>0.0.0.0:5000</code> or <code>[::]:5000</code>.</p>
<h2 id="set-the-password-before-public-exposure">Set the password before public exposure<a href="#set-the-password-before-public-exposure" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>changedetection.io is not a multi-user application with a first-account wizard. A default installation has no password. The <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Password-protection" title="official password documentation" target="_blank" rel="noopener noreferrer">official password documentation</a> instructs operators to set one in Settings.</p>
<p>Leave Caddy unconfigured. From your workstation, open an SSH tunnel and keep it running:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 5000:127.0.0.1:5000</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:5000</code>, open Settings, set a strong unique password, and save. Sign out, reload the page, and confirm the password is required and works. This order prevents an Internet visitor from choosing or bypassing the initial protection.</p>
<p>If the password is ever lost, follow the upstream reset procedure but use this deployment’s real container name, <code>changedetection</code>. Reset access only from an authenticated administrative session, then set a new password immediately.</p>
<h2 id="add-caddy-only-after-the-password-works">Add Caddy only after the password works<a href="#add-caddy-only-after-the-password-works" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create a tiny test resource on the same HTTPS origin. It gives the later watch test a URL you control rather than making a third-party site part of service health.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> root</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> /srv/changedetection-test</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s\n&#39;</span><span style="color:#9ECBFF"> &#39;changedetection test version 1&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">  sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#9ECBFF"> root:caddy</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 640</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span></span></code></pre>
<p>Keep the site in an imported snippet so it does not replace any existing Caddy hosts:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/changedetection.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">change.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    handle /changedetection-probe.txt {</span></span>
<span class="line"><span style="color:#9ECBFF">        root * /srv/changedetection-test</span></span>
<span class="line"><span style="color:#9ECBFF">        rewrite * /probe.txt</span></span>
<span class="line"><span style="color:#9ECBFF">        file_server</span></span>
<span class="line"><span style="color:#9ECBFF">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ECBFF">    handle {</span></span>
<span class="line"><span style="color:#9ECBFF">        reverse_proxy 127.0.0.1:5000</span></span>
<span class="line"><span style="color:#9ECBFF">    }</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://change.example.com/changedetection-probe.txt</span></span></code></pre>
<p>Caddy’s <a href="https://caddyserver.com/docs/automatic-https" title="automatic HTTPS" target="_blank" rel="noopener noreferrer">automatic HTTPS</a> obtains and renews the certificate when DNS and ports 80/443 are correct. The public root should now show the password-protected application.</p>
<h2 id="verify-system-information-and-a-real-change">Verify system information and a real change<a href="#verify-system-information-and-a-real-change" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Find the API key under Settings &gt; API. The <a href="https://changedetection.io/docs/api_v1/index.html" title="official API" target="_blank" rel="noopener noreferrer">official API</a> authenticates requests with the <code>x-api-key</code> header. Read the key without adding it to shell history:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">read</span><span style="color:#79B8FF"> -rsp</span><span style="color:#9ECBFF"> &#39;changedetection.io API key: &#39;</span><span style="color:#9ECBFF"> CD_API_KEY</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;\n&#39;</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> CD_API_KEY</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> CD_URL</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&#39;https://change.example.com&#39;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/systeminfo&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span></span></code></pre>
<p>Confirm that <code>version</code> is <code>0.55.8</code> and review <code>queue_size</code>, <code>overdue_watches</code>, <code>uptime</code>, and <code>watch_count</code>.</p>
<p>Create a watch using the default <code>html_requests</code> fetcher, trigger it, and wait for the first snapshot:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#E1E4E8">probe</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/changedetection-probe.txt&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">watch_uuid</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> POST</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Content-Type: application/json&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">jq</span><span style="color:#79B8FF"> -nc</span><span style="color:#79B8FF"> --arg</span><span style="color:#9ECBFF"> url &quot;</span><span style="color:#E1E4E8">$probe</span><span style="color:#9ECBFF">&quot; </span><span style="color:#79B8FF">\</span></span>
<span class="line"><span style="color:#9ECBFF">    &#39;{url:$url,title:&quot;Controlled Compose test&quot;,fetch_backend:&quot;html_requests&quot;}&#39;)&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  |</span><span style="color:#B392F0"> jq</span><span style="color:#79B8FF"> -er</span><span style="color:#9ECBFF"> &#39;.uuid&#39;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">?recheck=true&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#E1E4E8">  watch_json</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#9ECBFF">&quot;$(</span><span style="color:#B392F0">jq</span><span style="color:#79B8FF"> -r</span><span style="color:#9ECBFF"> &#39;.last_checked // 0&#39; </span><span style="color:#F97583">&lt;&lt;&lt;</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$watch_json</span><span style="color:#9ECBFF">&quot;)&quot;</span><span style="color:#F97583"> -gt</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#B392F0">    jq</span><span style="color:#9ECBFF"> &#39;{last_checked,last_error,history_n,fetch_backend}&#39;</span><span style="color:#F97583"> &lt;&lt;&lt;</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$watch_json</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span></code></pre>
<p>Change the controlled file, trigger another check, and wait until the API reports at least two snapshots:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s\n&#39;</span><span style="color:#9ECBFF"> &#39;changedetection test version 2&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">  sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#9ECBFF"> root:caddy</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 640</span><span style="color:#9ECBFF"> /srv/changedetection-test/probe.txt</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">?recheck=true&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#E1E4E8">  history_count</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">/history&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#9ECBFF"> &#39;length&#39;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">  [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$history_count</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -ge</span><span style="color:#79B8FF"> 2</span><span style="color:#E1E4E8"> ] &amp;&amp; </span><span style="color:#F97583">break</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">/difference/previous/latest?format=text&quot;</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &quot;x-api-key: </span><span style="color:#E1E4E8">$CD_API_KEY</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$CD_URL</span><span style="color:#9ECBFF">/api/v1/watch/</span><span style="color:#E1E4E8">$watch_uuid</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#79B8FF">unset</span><span style="color:#9ECBFF"> CD_API_KEY</span></span></code></pre>
<p>The diff must show the change from version 1 to version 2. One pass through this test exercises DNS, TLS, authentication, queueing, outbound HTTP, snapshot persistence, and diff generation.</p>
<h2 id="back-up-the-stopped-datastore">Back up the stopped datastore<a href="#back-up-the-stopped-datastore" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><code>/datastore</code> holds watches, history, snapshots, headers, notification URLs, browser steps, and application secrets. An archive of it may contain credentials or tokens, so give it mode 600, encrypt it, and copy it off the VPS.</p>
<p>Stop the container first so nothing writes mid-archive, then tar the volume from a helper container:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#E1E4E8">datastore_volume</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> inspect</span><span style="color:#79B8FF"> -f</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;{{range .Mounts}}{{if eq .Destination &quot;/datastore&quot;}}{{.Name}}{{end}}{{end}}&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  changedetection</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">archive</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;backups/changedetection-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> changedetection</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$datastore_volume</span><span style="color:#9ECBFF">:/datastore:ro&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$PWD</span><span style="color:#9ECBFF">/backups:/backup&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /datastore</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;/backup/$(</span><span style="color:#B392F0">basename</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;)&quot;</span><span style="color:#9ECBFF"> .</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span><span style="color:#9ECBFF"> changedetection</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;:&quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">tar</span><span style="color:#79B8FF"> -tzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#B392F0">sha256sum</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">.sha256&quot;</span></span></code></pre>
<p>Keep <code>compose.yml</code>, the Caddy configuration, and the checksum alongside the archive in encrypted backup storage.</p>
<h2 id="rehearse-an-isolated-same-version-restore">Rehearse an isolated same-version restore<a href="#rehearse-an-isolated-same-version-restore" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Restore into a throwaway environment rather than over production. The drill below builds a throwaway volume, network, and container with a timestamped name and starts the same 0.55.8 image that produced the backup. Keep the commands and the later cleanup in one shell:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#E1E4E8">BACKUP</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">ls</span><span style="color:#79B8FF"> -1</span><span style="color:#9ECBFF"> backups/changedetection-</span><span style="color:#79B8FF">*</span><span style="color:#9ECBFF">.tar.gz</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> tail</span><span style="color:#79B8FF"> -n</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#B392F0">sha256sum</span><span style="color:#79B8FF"> -c</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">.sha256&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">restore_id</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;changedetection-restore-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%Y%m%d%H%M%S)&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> volume</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> network</span><span style="color:#9ECBFF"> create</span><span style="color:#79B8FF"> --internal</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> --rm</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/datastore&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$PWD</span><span style="color:#9ECBFF">/backups:/backup:ro&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  alpine:3.22</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /datastore</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;/backup/$(</span><span style="color:#B392F0">basename</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">&quot;)&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> run</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --name</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --network</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -p</span><span style="color:#9ECBFF"> 127.0.0.1:15000:5000</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -v</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">:/datastore&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  ghcr.io/dgtlmoon/changedetection.io:0.55.8</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span></code></pre>
<p>Tunnel port 15000, log in with the restored password, inspect a few watches and their histories, and run the API system-information check against the restored copy. The internal Docker network blocks scheduled fetches and notification callbacks from leaving the test environment, so fetch errors are expected. Do not connect optional browser automation to production credentials during a recovery drill. Remove the test resources when finished:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> rm</span><span style="color:#79B8FF"> -f</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> volume</span><span style="color:#9ECBFF"> rm</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> network</span><span style="color:#9ECBFF"> rm</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_id</span><span style="color:#9ECBFF">&quot;</span></span></code></pre>
<h2 id="upgrade-with-a-recovery-point">Upgrade with a recovery point<a href="#upgrade-with-a-recovery-point" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the <a href="https://github.com/dgtlmoon/changedetection.io/releases" title="release notes" target="_blank" rel="noopener noreferrer">release notes</a>, take a fresh cold backup, and rehearse its restore. Then bump the version tag in <code>compose.yml</code> and redeploy:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/changedetection</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> config</span><span style="color:#79B8FF"> --quiet</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> changedetection</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=100</span><span style="color:#9ECBFF"> changedetection</span></span></code></pre>
<p>Repeat <code>/api/v1/systeminfo</code> and the two-version watch test. Keep the previous image tag and archive until those checks pass. If an upgrade migrates stored data, rollback means restoring the old image and its pre-upgrade <code>/datastore</code> together. Changing the image tag back on its own may leave migrated data in an unsupported state.</p>
<h2 id="add-a-browser-only-for-sites-that-require-it">Add a browser only for sites that require it<a href="#add-a-browser-only-for-sites-that-require-it" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The baseline deliberately uses the pure HTTP <code>html_requests</code> fetcher. It consumes less memory, does not execute target JavaScript, and stays within changedetection.io’s request-fetching path. Dynamic pages may require the optional Playwright-compatible <a href="https://github.com/dgtlmoon/changedetection.io/blob/0.55.8/docker-compose.yml" title="Sockpuppetbrowser service shown in the official Compose file" target="_blank" rel="noopener noreferrer">Sockpuppetbrowser service shown in the official Compose file</a>.</p>
<p>Treat that as a separate change. The upstream example at the time of writing uses <code>dgtlmoon/sockpuppetbrowser:latest</code> and may add <code>SYS_ADMIN</code>; its own comment says that capability may be excessive. Pin a specific browser tag, test whether it runs without the capability, set CPU, memory, process, and screenshot limits, and publish neither its port 3000 nor a VNC port.</p>
<p>Place only changedetection.io and the browser on a dedicated Docker bridge. Private Docker networking prevents a public control port, but it does not restrict outbound destinations. Apply host or provider egress rules that deny cloud metadata, loopback, link-local, RFC1918, management, and storage networks while allowing the explicit container-to-container browser endpoint. Review those rules after every network change.</p>
<p>As of 0.55.8, changedetection.io blocks IANA-restricted addresses by default, rechecks DNS at fetch time, validates every redirect hop, and rejects <code>file://</code> URLs, with defenses against DNS rebinding and URL-parser tricks; the <code>ALLOW_IANA_RESTRICTED_ADDRESSES</code> and <code>ALLOW_FILE_URI</code> switches both default to false. Resist the temptation to set <code>ALLOW_IANA_RESTRICTED_ADDRESSES=true</code> just to watch a NAS on your LAN. That global opt-in turns private addresses into valid targets for every permitted watch and makes stolen application access far more damaging. Browser traffic also needs network-layer controls because a full browser is not equivalent to the plain-request SSRF filter.</p>
<p>Those controls include fixes covered by the project’s <a href="https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-3c45-4pj5-ch7m" title="SSRF security advisory" target="_blank" rel="noopener noreferrer">SSRF security advisory</a>. They reduce exposure in the supported configuration; they are not a reason to remove egress filtering or grant watch creation to untrusted users.</p>
<h2 id="choosing-a-riven-cloud-vps-for-changedetectionio">Choosing a Riven Cloud VPS for changedetection.io<a href="#choosing-a-riven-cloud-vps-for-changedetectionio" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a personal watch list on the plain HTTP fetcher, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable starting point. Every plan ships with a 1 Gbps port and full root access. These are unmanaged VPS, which means the stack is yours to run and yours to control. If you add browser workers, keep long snapshot histories, or watch many pages at short intervals, step up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) for the extra memory and disk.</p>
<p>We offer Tokyo and Singapore. Pick the location that reaches both you and the sites you monitor well; a server close to your dashboard can still be far from a monitored origin. If you are on a mainland China network, the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> let you test real routes from your own connection before ordering. Current specs and prices are on the <a href="https://sa.net/pricing/" title="Riven Cloud VPS plans">Riven Cloud VPS plans</a> page.</p>
<p>Daily provider-side backups come with every plan and are a welcome safety net. Treat them as secondary, though: they can catch the application mid-write and they sit inside our recovery system, so the stopped <code>/datastore</code> archive, the encrypted off-server copy, and the restore drill above remain your primary recovery path.</p>
<h2 id="security-limits-and-non-fit-cases">Security limits and non-fit cases<a href="#security-limits-and-non-fit-cases" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A few habits keep the deployment healthy: keep the application password unique, protect API keys and notification tokens, review who can add watch URLs, and keep an eye on disk growth. Hiding the referrer does not make a fetch anonymous; the target still sees the VPS source IP, timing, request headers, and behavior, so be a polite client and respect site terms, robots policies where applicable, and sensible request rates.</p>
<p>Self-hosting is not always the right call, and that is fine. If nobody will own updates, outbound access policy, alert delivery, backups, and restore tests, the hosted changedetection.io service or another managed monitor will serve you better. A single VPS also cannot usefully monitor its own availability or network path, so give that job to an independent external monitor. And think twice when untrusted tenants can create arbitrary watches, when browser automation needs powerful access to internal systems, or when monitoring credentials call for formal secret management and audit controls.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host Forgejo with Docker]]></title>
        <id>https://sa.net/blog/self-host-forgejo/</id>
        <link href="https://sa.net/blog/self-host-forgejo/"/>
        <updated>2026-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy Forgejo 16.0.0 with Docker Compose and Caddy TLS, push over both HTTPS and SSH, and keep repository backups you have actually restored.]]></summary>
        <content type="html"><![CDATA[<p>Forgejo is a self-hosted software forge for Git repositories, issues, pull requests, packages, and related development work. Its canonical repository is on <a href="https://codeberg.org/forgejo/forgejo" title="Codeberg" target="_blank" rel="noopener noreferrer">Codeberg</a>, the project documentation is at <a href="https://forgejo.org/" title="forgejo.org" target="_blank" rel="noopener noreferrer">forgejo.org</a>, and the code is licensed under GPL-3.0-or-later. Check releases and report bugs there rather than on a GitHub mirror.</p>
<p>This tutorial deploys Forgejo 16.0.0, the current release when this article was checked on July 17, 2026, on a fresh Debian or Ubuntu VPS. It uses the standard rootful Docker image and SQLite. Caddy terminates HTTPS for <code>git.example.com</code>, the web listener stays on <code>127.0.0.1:3000</code>, and Git SSH uses public host port 2222. The path includes an actual clone, commit, and push rather than stopping when the dashboard loads.</p>
<p>SQLite suits a straightforward forge with low-to-moderate activity. Choose PostgreSQL before launch if you expect high concurrency or already operate it well. Changing databases also changes the backup and restore procedure.</p>
<p>Forgejo is one of ten projects in our broader guide to <a href="https://sa.net/blog/best-self-hosted-apps/" title="self-hosted apps for a VPS">self-hosted apps for a VPS</a>. That guide compares the candidates; this article covers the full deployment path for one of them.</p>
<h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Prepare the following:</p>
<ul>
<li>a Debian or Ubuntu VPS with sudo or root access;</li>
<li>an A record for <code>git.example.com</code> pointing to the VPS, and an AAAA record only when IPv6 is configured correctly;</li>
<li>TCP 80 and 443 for the web proxy, TCP 2222 for Forgejo Git SSH, and your separate administrative SSH port;</li>
<li>Git and an SSH client on your workstation;</li>
<li>off-server storage large enough for repositories, SQLite, configuration, LFS objects, packages, and attachments.</li>
</ul>
<p>Harden administrative access first with the <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="secure SSH guide">secure SSH guide</a>. This tutorial assumes the host’s own SSH daemon remains on port 22, so Forgejo can use 2222 without a collision. If you moved your administrative sshd to 2222, for example by following that guide’s worked example, pick a different host port for Forgejo’s Git SSH, such as 2223, and use it consistently in every firewall rule, port mapping, and clone URL below.</p>
<p>Install Docker Engine and the Compose plugin using Docker’s convenience script (read through it first if you want to see what it will change):</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sh</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>Because the script runs as root and adds Docker’s package repository, hosts with stricter policies should install Docker from the distribution’s packages instead. As of July 2026, Forgejo’s upgrade documentation requires Docker 20.10.6 or newer, which the version output confirms.</p>
<p>Install Caddy, Git, and UFW from the distro repository. See Caddy’s <a href="https://caddyserver.com/docs/install" title="official package instructions" target="_blank" rel="noopener noreferrer">official package instructions</a> if your distribution needs its upstream repository.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> git</span><span style="color:#9ECBFF"> ufw</span></span></code></pre>
<p>Set the firewall before starting the forge:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 2222/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span></code></pre>
<p>Replace <code>22</code> if your administrative SSH listener uses another port. Mirror these rules in the provider’s cloud firewall or security group. Do not open port 3000 or a database port publicly.</p>
<h2 id="create-the-forgejo-deployment">Create the Forgejo deployment<a href="#create-the-forgejo-deployment" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The standard image stores its state under <code>/data</code>. Create a host directory owned by the UID and GID used in the Compose file:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#79B8FF"> -o</span><span style="color:#79B8FF"> 1000</span><span style="color:#79B8FF"> -g</span><span style="color:#79B8FF"> 1000</span><span style="color:#9ECBFF"> /opt/forgejo/forgejo</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span></code></pre>
<p>Create <code>/opt/forgejo/compose.yml</code>. The layout follows Forgejo’s official Docker and configuration documentation; the release pin, loopback web binding, public port 2222, disabled registration, and log limits are choices made for this tutorial.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> compose.yml</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;YAML&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">services:</span></span>
<span class="line"><span style="color:#9ECBFF">  forgejo:</span></span>
<span class="line"><span style="color:#9ECBFF">    image: codeberg.org/forgejo/forgejo:16.0.0</span></span>
<span class="line"><span style="color:#9ECBFF">    container_name: forgejo</span></span>
<span class="line"><span style="color:#9ECBFF">    restart: unless-stopped</span></span>
<span class="line"><span style="color:#9ECBFF">    environment:</span></span>
<span class="line"><span style="color:#9ECBFF">      USER_UID: &quot;1000&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      USER_GID: &quot;1000&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      FORGEJO__server__DOMAIN: &quot;git.example.com&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      FORGEJO__server__ROOT_URL: &quot;https://git.example.com/&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      FORGEJO__server__SSH_DOMAIN: &quot;git.example.com&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      FORGEJO__server__SSH_PORT: &quot;2222&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      FORGEJO__service__DISABLE_REGISTRATION: &quot;true&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    volumes:</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./forgejo:/data</span></span>
<span class="line"><span style="color:#9ECBFF">      - /etc/localtime:/etc/localtime:ro</span></span>
<span class="line"><span style="color:#9ECBFF">    ports:</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;127.0.0.1:3000:3000&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;2222:22&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    logging:</span></span>
<span class="line"><span style="color:#9ECBFF">      options:</span></span>
<span class="line"><span style="color:#9ECBFF">        max-size: &quot;10m&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">        max-file: &quot;3&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">YAML</span></span></code></pre>
<p>The image is pinned to the exact release checked for this article. An exact tag prevents an unattended major migration, but it also stops automatic fixes, so read the canonical <a href="https://codeberg.org/forgejo/forgejo/releases" title="Forgejo releases" target="_blank" rel="noopener noreferrer">Forgejo releases</a> and update the tag deliberately later.</p>
<p>Forgejo converts variables named <code>FORGEJO__[SECTION]__[KEY]</code> into <code>app.ini</code> settings. <code>ROOT_URL</code> controls browser links and HTTPS clone URLs. <code>SSH_DOMAIN</code> and <code>SSH_PORT</code> make displayed SSH clone URLs use <code>git.example.com:2222</code>, while Docker forwards that host port to port 22 inside the standard container.</p>
<p>The <code>./forgejo:/data</code> mount contains <code>app.ini</code>, repositories, the SQLite database, LFS objects, attachments, packages, SSH-related state, and generated secrets. In particular, losing <code>[security].SECRET_KEY</code> can make encrypted data such as 2FA secrets unreadable.</p>
<p>This guide uses the standard image. It does not teach the rootless variant because its paths and port mappings differ. Validate those details against the exact image before designing a rootless deployment.</p>
<p>Start the service, wait for the web listener, then confirm port 3000 is loopback-only and port 2222 is public:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#B392F0"> curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#9ECBFF"> http://127.0.0.1:3000/</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$attempt</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -eq</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &quot;Forgejo did not become ready within 120 seconds&quot;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#79B8FF"> -E</span><span style="color:#9ECBFF"> &#39;:(3000|2222)&#39;</span></span></code></pre>
<h2 id="add-caddy-and-https">Add Caddy and HTTPS<a href="#add-caddy-and-https" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Use a dedicated subdomain. Forgejo’s <a href="https://forgejo.org/docs/latest/admin/setup/reverse-proxy/" title="reverse-proxy documentation" target="_blank" rel="noopener noreferrer">reverse-proxy documentation</a> warns against putting a forge below a subpath on an origin that also serves user-controlled content because same-origin and CSRF boundaries become harder to protect.</p>
<p>Create an imported Caddy site snippet. This keeps existing hosts intact even when Forgejo is not the first service on the VPS:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/forgejo.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">git.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:3000</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<p>Caddy requests and renews the certificate when DNS and both firewalls permit TCP 80/443. Git SSH does not pass through Caddy. Clients connect directly to port 2222.</p>
<h2 id="complete-the-first-run-installer">Complete the first-run installer<a href="#complete-the-first-run-installer" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>An uninitialized public forge can be claimed by whoever reaches its installer first. Open <code>https://git.example.com</code> only when you are ready to finish setup. For stricter staging, keep Caddy stopped and tunnel the loopback listener with <code>ssh -L 3000:127.0.0.1:3000 root@SERVER_IP</code>, then browse to <code>http://127.0.0.1:3000</code>.</p>
<p>In the installer:</p>
<ol>
<li>Select <strong>SQLite3</strong> and keep its database inside <code>/data</code>.</li>
<li>Confirm the server domain is <code>git.example.com</code>.</li>
<li>Confirm the Forgejo base URL is <code>https://git.example.com/</code>.</li>
<li>Confirm the SSH domain is <code>git.example.com</code> and the SSH port is <code>2222</code>.</li>
<li>Expand the administrator section and create the first administrator with a strong unique password and a real email address.</li>
<li>Finish the installation and sign in.</li>
</ol>
<p>The first user created during installation receives administrator access. Registration is already disabled through Compose. Verify that a logged-out visitor cannot register, then enable 2FA on the administrator account. Configure SMTP before depending on password reset, email confirmation, or notifications.</p>
<h2 id="verify-https-and-ssh-git-workflows">Verify HTTPS and SSH Git workflows<a href="#verify-https-and-ssh-git-workflows" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Run the server-side checks:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> https://git.example.com/</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  forgejo</span><span style="color:#9ECBFF"> doctor</span><span style="color:#9ECBFF"> check</span><span style="color:#79B8FF"> --all</span><span style="color:#79B8FF"> --log-file</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> cat</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span></code></pre>
<p>In the web UI, create a disposable repository named <code>docker-test</code> under your account. Leave it empty for the first clone. Forgejo’s HTTP clone path uses HTTPS here, so credentials and repository data are protected in transit. On your workstation, replace <code>YOUR_USER</code> with the account name:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> clone</span><span style="color:#9ECBFF"> https://git.example.com/YOUR_USER/docker-test.git</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> docker-test</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> config</span><span style="color:#9ECBFF"> user.name</span><span style="color:#9ECBFF"> &quot;Forgejo Test&quot;</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> config</span><span style="color:#9ECBFF"> user.email</span><span style="color:#9ECBFF"> &quot;forgejo-test@example.com&quot;</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s\n&#39;</span><span style="color:#9ECBFF"> &#39;Forgejo end-to-end test&#39;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> README.md</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> README.md</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> commit</span><span style="color:#79B8FF"> -m</span><span style="color:#9ECBFF"> &quot;Test HTTPS push&quot;</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> branch</span><span style="color:#79B8FF"> -M</span><span style="color:#9ECBFF"> main</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> push</span><span style="color:#79B8FF"> -u</span><span style="color:#9ECBFF"> origin</span><span style="color:#9ECBFF"> main</span></span></code></pre>
<p>Enter the Forgejo username and password or personal access token when Git prompts. Refresh the browser and confirm the commit and file appear. This proves that the reverse proxy, application, SQLite database, and repository storage agree.</p>
<p>Now test SSH. Add your workstation’s public SSH key under <strong>Settings &gt; SSH/GPG Keys</strong>, then run:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -F</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -p</span><span style="color:#79B8FF"> 2222</span><span style="color:#9ECBFF"> git@git.example.com</span></span></code></pre>
<p>Forgejo should recognize the account and deny interactive shell access. Change the existing clone to SSH and push a second commit:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> remote</span><span style="color:#9ECBFF"> set-url</span><span style="color:#9ECBFF"> origin</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  ssh://git@git.example.com:2222/YOUR_USER/docker-test.git</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s\n&#39;</span><span style="color:#9ECBFF"> &#39;SSH push works&#39;</span><span style="color:#F97583"> &gt;&gt;</span><span style="color:#9ECBFF"> README.md</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> README.md</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> commit</span><span style="color:#79B8FF"> -m</span><span style="color:#9ECBFF"> &quot;Test SSH push&quot;</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> push</span></span></code></pre>
<p>Confirm the second commit in the web UI. Delete the disposable repository after the backup test if you do not need it.</p>
<h2 id="plan-for-storage-and-runners">Plan for storage and runners<a href="#plan-for-storage-and-runners" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Git history is only part of forge storage. Git LFS, release attachments, package registries, repository mirrors, and Actions artifacts can grow independently. Set quotas where appropriate and monitor the filesystem instead of estimating capacity from repository count.</p>
<p>Run Forgejo Actions runners as separate services with their own lifecycle and isolation. Workflow code may be controlled by repository contributors. Do not attach a runner to the Forgejo container or casually give it the host Docker socket; that can turn a CI job into host-level access. Follow the project’s separate Actions security documentation before enabling untrusted workflows.</p>
<h2 id="back-up-and-rehearse-restoration">Back up and rehearse restoration<a href="#back-up-and-rehearse-restoration" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Forgejo’s official guidance requires a synchronized point-in-time copy of every storage component. For this single-volume SQLite design, a stopped-container archive of <code>/data</code> captures the database, repositories, configuration, packages, LFS objects, and generated secrets together:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /var/backups/forgejo</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> manager</span><span style="color:#9ECBFF"> flush-queues</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> --numeric-owner</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/forgejo</span><span style="color:#79B8FF"> -czf</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;/var/backups/forgejo/forgejo-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span><span style="color:#9ECBFF"> forgejo</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#B392F0"> curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#9ECBFF"> http://127.0.0.1:3000/</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$attempt</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -eq</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &quot;Forgejo did not restart within 120 seconds&quot;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span></code></pre>
<p>Encrypt and copy the archive off-host. If you move to PostgreSQL or MySQL, stop or quiesce Forgejo and take the database vendor’s native dump as part of the same recovery point. External object storage and remote repository paths must be captured with it. <code>forgejo dump</code> can be useful, but the official upgrade guide warns about restoring its SQL output for external databases; do not treat it as a substitute for <code>pg_dump</code> or <code>mysqldump</code>.</p>
<p>Restore onto an isolated test VPS with Forgejo 16.0.0. Transfer one archive, set <code>BACKUP</code> to its real path, and keep the empty directory as a rollback point:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> BACKUP</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">/path/to/forgejo-YYYY-MM-DD-HHMMSS.tar.gz</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mv</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> forgejo.empty</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> --numeric-owner</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/forgejo</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#79B8FF"> -R</span><span style="color:#9ECBFF"> 1000:1000</span><span style="color:#9ECBFF"> /opt/forgejo/forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#B392F0"> curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#9ECBFF"> http://127.0.0.1:3000/</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$attempt</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -eq</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &quot;Restored Forgejo did not become ready within 120 seconds&quot;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  forgejo</span><span style="color:#9ECBFF"> doctor</span><span style="color:#9ECBFF"> check</span><span style="color:#79B8FF"> --all</span><span style="color:#79B8FF"> --log-file</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> cat</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span></code></pre>
<p>Log in, clone the restored <code>docker-test</code> repository over HTTPS, push a commit over SSH, and check any LFS objects, packages, or attachments you use. Upgrade only after the restored copy passes those checks.</p>
<p>Every Riven Cloud plan includes daily provider backups, which make a good extra layer, but they are no substitute for an application-consistent Forgejo backup and a rehearsed restore, so keep both.</p>
<h2 id="upgrade-deliberately">Upgrade deliberately<a href="#upgrade-deliberately" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the release notes and known upgrade issues, take a consistent backup, then flush queues:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/forgejo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> manager</span><span style="color:#9ECBFF"> flush-queues</span></span></code></pre>
<p>Change <code>16.0.0</code> in <code>compose.yml</code> to the reviewed release and run:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#B392F0"> curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#9ECBFF"> http://127.0.0.1:3000/</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$attempt</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -eq</span><span style="color:#79B8FF"> 24</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &quot;Upgraded Forgejo did not become ready within 120 seconds&quot;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  forgejo</span><span style="color:#9ECBFF"> doctor</span><span style="color:#9ECBFF"> check</span><span style="color:#79B8FF"> --all</span><span style="color:#79B8FF"> --log-file</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> forgejo</span><span style="color:#9ECBFF"> cat</span><span style="color:#9ECBFF"> /tmp/doctor.log</span></span></code></pre>
<p>Repeat login, HTTPS clone, SSH push, and storage checks. Major upgrades require an explicit tag change and human verification. Forgejo performs database migrations at startup; do not assume a downgrade will be safe after those migrations.</p>
<h2 id="choosing-a-riven-cloud-vps-for-forgejo">Choosing a Riven Cloud VPS for Forgejo<a href="#choosing-a-riven-cloud-vps-for-forgejo" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a small private forge with ordinary source repositories, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a solid start. Storage is usually the first thing you outgrow: Git LFS, packages, release assets, mirrors, and CI artifacts add up much faster than plain Git history. When they do, Ultra raises the NVMe to 80 GB and transfer to 2 TB, and Max provides 160 GB and 4 TB. Watch retained data and clone traffic rather than sizing by repository count, and keep Actions runners on separately isolated capacity.</p>
<p>All plans include a 1 Gbps port, full root access, daily backups, and deployment in Tokyo or Singapore. Our plans are unmanaged, so you keep full control of the stack you just built. Put the forge near the developers and systems that clone, push, fetch packages, and receive webhooks; teams connecting from mainland China can compare the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from the office or carrier network to see which location feels closer. The <a href="https://sa.net/pricing/" title="pricing page">pricing page</a> lists the current storage and transfer limits.</p>
<h2 id="security-limits">Security limits<a href="#security-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep public registration disabled unless you can handle spam, abuse reports, mail reputation, and user storage. If you open it, use Forgejo’s recommended CAPTCHA and username-cooldown controls. Protect administrator accounts with 2FA, review webhook targets and secrets, and leave local-network access for repository migrations disabled unless you have a specific reason to permit it.</p>
<p>Third-party themes, Git hooks, templates, extensions, and runners execute or influence trusted server-side work. Add them only with a review and backup plan.</p>
<h2 id="when-not-to-self-host">When not to self-host<a href="#when-not-to-self-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>If source availability is business-critical and nobody on the team wants to own upgrades, monitoring, mail, incident response, and restore tests, a managed forge will serve you better. A single VPS may also fall short of compliance or geographic-redundancy requirements. And if you need to run untrusted workflow code but cannot isolate runners from the forge and host, managed CI is the safer choice.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host ntfy with Docker]]></title>
        <id>https://sa.net/blog/self-host-ntfy/</id>
        <link href="https://sa.net/blog/self-host-ntfy/"/>
        <updated>2026-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy a private ntfy server with Docker Compose and Caddy TLS, lock topics behind ACLs, and check that your phone actually gets the alerts.]]></summary>
        <content type="html"><![CDATA[<p>ntfy turns an HTTP request into a notification in a browser or mobile app. A useful private deployment needs more than a running container: it needs access control, TLS, correct proxy behavior, persistent authentication data, and a test from publisher to subscriber.</p>
<p>This guide builds that deployment on a Debian or Ubuntu VPS. The application listens only on <code>127.0.0.1:2586</code>; Caddy or Nginx owns the public ports. We use the official <code>binwiederhier/ntfy:v2.26.0</code> image, which was the latest release when checked on July 17, 2026, with ntfy’s SQLite storage, the conservative choice for one unmanaged server.</p>
<p>One warning before anything else: ntfy allows anonymous users to read and write every topic by default, and adding an <code>auth-file</code> alone does not make the server private. The configuration below sets <code>auth-default-access: deny-all</code>, then grants access with named users and topic ACLs.</p>
<h2 id="prerequisites-prepare-the-vps-dns-and-firewall">Prerequisites: prepare the VPS, DNS, and firewall<a href="#prerequisites-prepare-the-vps-dns-and-firewall" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need a Linux VPS with root or sudo access, a DNS record for <code>ntfy.example.com</code>, and Docker Engine with the Compose plugin. Point an A record at the server’s IPv4 address. Add an AAAA record only if the VPS, firewall, and reverse proxy all serve IPv6.</p>
<p>Our VPS plans are unmanaged, so you run Docker, TLS, the firewall, monitoring, backups, and upgrades yourself and keep full control of the stack. If you want one server for several small services, our <a href="https://sa.net/blog/best-self-hosted-apps/" title="self-hosted apps guide">self-hosted apps guide</a> can help you decide what belongs on the same VPS.</p>
<p>Install Docker Engine and the Compose plugin. Docker’s convenience script is the quickest way to get both; anyone uneasy about running a downloaded script as root can install from the distribution’s packages instead:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> bash</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>The rest of the guide assumes your user can run Docker without <code>sudo</code>. Either prefix Docker commands with <code>sudo</code>, or follow Docker’s documented post-install procedure.</p>
<p>Allow SSH before enabling or changing a firewall; if you have not hardened that port yet, our walkthrough of <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="securing SSH on a VPS">securing SSH on a VPS</a> covers it. If UFW is already active, add only the web ports:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>Mirror those rules in any provider firewall. Do not open port 2586 publicly. After DNS propagates, <code>getent ahosts ntfy.example.com</code> should show this VPS.</p>
<h2 id="create-the-ntfy-compose-project">Create the ntfy Compose project<a href="#create-the-ntfy-compose-project" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep the project in one directory so its configuration and storage paths remain unambiguous:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#9ECBFF"> cache</span><span style="color:#9ECBFF"> data</span></span></code></pre>
<p>Create <code>/opt/ntfy/compose.yaml</code>. The loopback binding, deny-by-default policy, explicit limits, and file permissions are hardening choices on top of ntfy’s official example and configuration reference.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  ntfy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">binwiederhier/ntfy:v2.26.0</span></span>
<span class="line"><span style="color:#85E89D">    container_name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ntfy</span></span>
<span class="line"><span style="color:#85E89D">    command</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">serve</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      TZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:2586:80&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./server.yml:/etc/ntfy/server.yml:ro</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./cache:/var/cache/ntfy</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./data:/var/lib/ntfy</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">        [</span></span>
<span class="line"><span style="color:#9ECBFF">          &quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF">          &quot;wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo &#39;</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">healthy</span><span style="color:#79B8FF">\&quot;\\</span><span style="color:#9ECBFF">s*:</span><span style="color:#79B8FF">\\</span><span style="color:#9ECBFF">s*true&#39; || exit 1&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        ]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">30s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">3</span></span>
<span class="line"><span style="color:#85E89D">      start_period</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">20s</span></span>
<span class="line"><span style="color:#85E89D">    init</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span></code></pre>
<p>Create <code>/opt/ntfy/server.yml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">base-url</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;https://ntfy.example.com&quot;</span></span>
<span class="line"><span style="color:#85E89D">behind-proxy</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">cache-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;/var/cache/ntfy/cache.db&quot;</span></span>
<span class="line"><span style="color:#85E89D">cache-duration</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;12h&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">attachment-cache-dir</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;/var/cache/ntfy/attachments&quot;</span></span>
<span class="line"><span style="color:#85E89D">attachment-total-size-limit</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;5G&quot;</span></span>
<span class="line"><span style="color:#85E89D">attachment-file-size-limit</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;15M&quot;</span></span>
<span class="line"><span style="color:#85E89D">attachment-expiry-duration</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3h&quot;</span></span>
<span class="line"><span style="color:#85E89D">visitor-attachment-total-size-limit</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;100M&quot;</span></span>
<span class="line"><span style="color:#85E89D">visitor-attachment-daily-bandwidth-limit</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;500M&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">auth-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;/var/lib/ntfy/user.db&quot;</span></span>
<span class="line"><span style="color:#85E89D">auth-default-access</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;deny-all&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">enable-login</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">require-login</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">enable-signup</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># Optional for prompt delivery to the official iOS app:</span></span>
<span class="line"><span style="color:#6A737D"># upstream-base-url: &quot;https://ntfy.sh&quot;</span></span></code></pre>
<p>Keep the project files private too:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> compose.yaml</span><span style="color:#9ECBFF"> server.yml</span></span></code></pre>
<p>The 12-hour message retention and the attachment limits are ntfy’s documented defaults, written out here so you can review them. They are limits, not a sizing recommendation. <code>cache/cache.db</code> and <code>cache/attachments/</code> hold temporary messages and files. <code>data/user.db</code> is durable security state: users, password hashes, tokens, and ACLs.</p>
<p>Start ntfy and check its health:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:2586/v1/health</span></span></code></pre>
<p>The response should contain <code>{&quot;healthy&quot;:true}</code>.</p>
<h2 id="create-users-and-topic-acls">Create users and topic ACLs<a href="#create-users-and-topic-acls" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>There is no default administrator. Add one interactively; the password is hashed into <code>data/user.db</code> rather than written into Compose:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> user</span><span style="color:#9ECBFF"> add</span><span style="color:#79B8FF"> --role=admin</span><span style="color:#9ECBFF"> admin</span></span></code></pre>
<p>An administrator can read and write every topic. For automation, a regular account with a narrow ACL is safer. These commands create a publisher that may write, but not read, the <code>backups</code> topic:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> user</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> publisher</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> access</span><span style="color:#9ECBFF"> publisher</span><span style="color:#9ECBFF"> backups</span><span style="color:#9ECBFF"> write-only</span></span></code></pre>
<p>Create a separate subscriber if a person or service needs read-only access:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> user</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> subscriber</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> access</span><span style="color:#9ECBFF"> subscriber</span><span style="color:#9ECBFF"> backups</span><span style="color:#9ECBFF"> read-only</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> access</span></span></code></pre>
<p>You can create a client token so scripts never see the account password:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> token</span><span style="color:#9ECBFF"> add</span><span style="color:#79B8FF"> --expires=90d</span><span style="color:#79B8FF"> --label=</span><span style="color:#9ECBFF">&quot;tutorial-client&quot;</span><span style="color:#9ECBFF"> publisher</span></span></code></pre>
<p>Store the returned token in a secret manager. ntfy tokens inherit the user’s permissions; they have no separate scopes.</p>
<h2 id="put-ntfy-behind-https">Put ntfy behind HTTPS<a href="#put-ntfy-behind-https" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>TLS is required because Basic credentials and bearer tokens must never cross plaintext HTTP. <code>behind-proxy: true</code> also matters: without it, ntfy sees the proxy as every visitor and applies one shared rate-limit bucket.</p>
<p>Caddy’s <a href="https://caddyserver.com/docs/automatic-https" title="automatic HTTPS" target="_blank" rel="noopener noreferrer">automatic HTTPS</a> keeps the proxy configuration small. Install Caddy through its official distro instructions, then add this site to <code>/etc/caddy/Caddyfile</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>ntfy.example.com {</span></span>
<span class="line"><span>    reverse_proxy 127.0.0.1:2586</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Validate and reload it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<p>Caddy forwards client information and supports WebSocket upgrades by default. For Nginx, use ntfy’s <a href="https://docs.ntfy.sh/config/#nginxapache2caddy" title="official reverse-proxy example" target="_blank" rel="noopener noreferrer">official reverse-proxy example</a>, not a bare <code>proxy_pass</code>: preserve <code>Host</code>, append <code>X-Forwarded-For</code>, set <code>X-Forwarded-Proto</code>, use HTTP/1.1 with <code>Upgrade</code> and <code>Connection</code>, disable response buffering for streaming subscriptions, allow request bodies up to your attachment limit, and set long read and send timeouts. Terminate TLS with a valid certificate on port 443 and redirect port 80 to HTTPS.</p>
<p>Verify the public endpoint:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://ntfy.example.com/v1/health</span></span></code></pre>
<h2 id="run-an-end-to-end-notification-test">Run an end-to-end notification test<a href="#run-an-end-to-end-notification-test" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>First prove that anonymous publishing is blocked:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;%{http_code}\n&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -d</span><span style="color:#9ECBFF"> &#39;anonymous publish should fail&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://ntfy.example.com/backups</span></span></code></pre>
<p>The result must not be a 2xx status. A 401 or 403 confirms the private default is in effect.</p>
<p>In the ntfy web app or mobile app, add <code>https://ntfy.example.com</code> as the server, sign in as <code>subscriber</code>, and subscribe to <code>backups</code>. Then publish with the restricted account:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -u</span><span style="color:#9ECBFF"> &#39;publisher:YOUR_PASSWORD&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Title: Backup test&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Priority: high&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -d</span><span style="color:#9ECBFF"> &#39;ntfy end-to-end test&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://ntfy.example.com/backups</span></span></code></pre>
<p>Confirm the message appears in the subscribed browser or phone. Polling the API gives a second check:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -u</span><span style="color:#9ECBFF"> &#39;subscriber:YOUR_PASSWORD&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;https://ntfy.example.com/backups/json?poll=1&amp;since=10m&#39;</span></span></code></pre>
<p>Now test persistence within the 12-hour cache window:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> ntfy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://ntfy.example.com/v1/health</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -u</span><span style="color:#9ECBFF"> &#39;subscriber:YOUR_PASSWORD&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;https://ntfy.example.com/backups/json?poll=1&amp;since=10m&#39;</span></span></code></pre>
<p>For an attachment test, create a harmless file smaller than 15 MB and upload it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;attachment persistence test\n&#39;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /tmp/ntfy-test.txt</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -u</span><span style="color:#9ECBFF"> &#39;publisher:YOUR_PASSWORD&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Filename: ntfy-test.txt&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -T</span><span style="color:#9ECBFF"> /tmp/ntfy-test.txt</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://ntfy.example.com/backups</span></span>
<span class="line"><span style="color:#B392F0">rm</span><span style="color:#9ECBFF"> /tmp/ntfy-test.txt</span></span></code></pre>
<p>The subscriber should receive an attachment link hosted at <code>ntfy.example.com</code>. Treat that URL as a capability link: <a href="https://github.com/binwiederhier/ntfy/blob/v2.26.0/server/server.go#L726-L739" title="ntfy’s file handler serves the attachment by message ID" target="_blank" rel="noopener noreferrer">ntfy’s file handler serves the attachment by message ID</a>, without applying the topic ACL to the download. Anyone who obtains the URL can fetch the file until it expires. Do not send sensitive attachments through this path; use an authenticated object-delivery system when each download must be authorized.</p>
<p>For a harmless test file, download it before the configured three-hour expiry, restart ntfy, and confirm it remains available during that window. ntfy manages the attachment directory and deletes expired or unreferenced files, so never share the directory with another application.</p>
<h2 id="understand-the-ios-delivery-trade-off">Understand the iOS delivery trade-off<a href="#understand-the-ios-delivery-trade-off" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Prompt delivery to the official iOS app requires an APNS-connected upstream, normally <code>https://ntfy.sh</code>. Uncomment <code>upstream-base-url</code> and recreate ntfy if that delivery behavior matters. According to ntfy’s <a href="https://docs.ntfy.sh/config/#ios-instant-notifications" title="iOS documentation" target="_blank" rel="noopener noreferrer">iOS documentation</a>, the upstream receives the message ID and a SHA-256-derived topic identifier, not the message body.</p>
<p>Leaving the setting disabled avoids that upstream dependency, but iOS notifications can be delayed significantly. A self-hosted ntfy server therefore does not promise fully independent, instant iOS push. Android normally keeps an instant-delivery connection to the self-hosted server; using FCM requires a custom app build and Firebase configuration.</p>
<h2 id="back-up-and-restore-ntfy">Back up and restore ntfy<a href="#back-up-and-restore-ntfy" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>ntfy has no dedicated backup tool. Stop the container, archive the project directory, and start it again:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#E1E4E8">archive</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;backups/ntfy-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> ntfy</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> compose.yaml</span><span style="color:#9ECBFF"> server.yml</span><span style="color:#9ECBFF"> data</span><span style="color:#9ECBFF"> cache</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$archive</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span><span style="color:#9ECBFF"> ntfy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> --retry</span><span style="color:#79B8FF"> 12</span><span style="color:#79B8FF"> --retry-delay</span><span style="color:#79B8FF"> 5</span><span style="color:#79B8FF"> --retry-all-errors</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  http://127.0.0.1:2586/v1/health</span></span></code></pre>
<p><code>data/user.db</code> is the part you cannot lose: it holds accounts, tokens, and ACLs. The message cache and attachments are temporary by design; a minimal backup may omit <code>cache</code>, but document that recovery will then not include recent messages or files.</p>
<p>Encrypt the archive before transferring it off the VPS. File permissions protect it on this host, not at the destination.</p>
<p>To restore, stop ntfy and move the damaged project aside. This example assumes the backup archive is still under the old project’s <code>backups</code> directory; list that directory first and replace the <code>ntfy-YYYY-MM-DD-HHMMSS.tar.gz</code> placeholder below with the real filename, which carries both the date and the time of the backup:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> ntfy</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mv</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy.failed</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#B392F0">ls</span><span style="color:#9ECBFF"> /opt/ntfy.failed/backups/</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> /opt/ntfy.failed/backups/ntfy-YYYY-MM-DD-HHMMSS.tar.gz</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#79B8FF"> -R</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;:&quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/ntfy</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> data</span><span style="color:#9ECBFF"> cache</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://ntfy.example.com/v1/health</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> ntfy</span><span style="color:#9ECBFF"> access</span></span></code></pre>
<p>Start the same ntfy version first. Verify an authenticated publish and a cached attachment before upgrading. Do not run <code>docker compose down -v</code>; it is unnecessary here and a dangerous habit around stateful Compose projects.</p>
<p>Every one of our plans includes daily backups, a solid fallback when the entire host needs to come back. Keep the stopped archive, the rehearsed restore, and an encrypted off-server copy in your routine as well; they are what guarantee an application-consistent <code>data/user.db</code>.</p>
<h2 id="upgrade-without-losing-state">Upgrade without losing state<a href="#upgrade-without-losing-state" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the <a href="https://github.com/binwiederhier/ntfy/releases" title="ntfy release notes" target="_blank" rel="noopener noreferrer">ntfy release notes</a>, take a fresh backup, and change the image tag in <code>compose.yaml</code> only after reviewing the target release. Then run:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> ntfy</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://ntfy.example.com/v1/health</span></span></code></pre>
<p>Repeat the anonymous rejection and authenticated delivery tests. Keep the old archive until the new version has survived a restart and delivered an attachment.</p>
<h2 id="choosing-a-riven-cloud-vps-for-ntfy">Choosing a Riven Cloud VPS for ntfy<a href="#choosing-a-riven-cloud-vps-for-ntfy" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>ntfy itself is a light workload. For a private, authenticated server handling alerts from a handful of machines, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is more than enough. Consider Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) or Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) if you expect many subscribers, long retention, or frequent large attachments. Whichever plan you pick, keep the ACLs and quotas above in place; access control is what keeps transfer and disk usage predictable.</p>
<p>Choose Tokyo or Singapore based on where your publishers and subscribers sit. If clients connect from mainland China, a quick test from the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> on your own carrier, at the time of day you care about, will tell you more than any route description. The <a href="https://sa.net/pricing/" title="Riven Cloud VPS plan comparison">Riven Cloud VPS plan comparison</a> shows the current NVMe and monthly-transfer limits.</p>
<h2 id="security-limits-and-when-not-to-self-host">Security limits and when not to self-host<a href="#security-limits-and-when-not-to-self-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Publicly writable ntfy topics can become notification spam, phishing, attachment hosting, or unexpected VPS transfer. ntfy includes request, subscription, and attachment limits, but those controls do not turn an anonymous service into a private one. Keep <code>deny-all</code>, grant narrow ACLs, protect tokens, monitor logs and disk usage, and patch both Docker and ntfy.</p>
<p>Hosted ntfy or another managed notification service is the better fit when nobody has time to maintain TLS, monitoring, updates, and restore tests; when you need contractual availability or delivery guarantees; or when public anonymous topics are part of the product and running an abuse desk is not realistic. And if dependable iOS push is the hard requirement, be clear-eyed about the trade. With <code>upstream-base-url</code> set, every notification sends a delivery poke through ntfy.sh, though the upstream sees only the message ID and a hashed topic identifier while the content stays on your server. That is a dependency on upstream infrastructure rather than a content leak, but it does mean your alerting is only as available as ntfy.sh; if that dependency is unacceptable and instant iOS delivery is non-negotiable, hosted delivery is the more practical choice.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host FreshRSS with Docker]]></title>
        <id>https://sa.net/blog/self-host-freshrss/</id>
        <link href="https://sa.net/blog/self-host-freshrss/"/>
        <updated>2026-07-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy FreshRSS 1.29.1 with Docker Compose and Caddy TLS, schedule the feed refreshes, and rehearse a full restore of the reader.]]></summary>
        <content type="html"><![CDATA[<p>FreshRSS is an AGPL-3.0-licensed web feed reader that keeps subscriptions, article state, and feed fetching on a server you operate. This guide deploys the official <code>freshrss/freshrss:1.29.1</code> image, the current release when this article was checked on July 16, 2026, on a fresh Debian or Ubuntu VPS, with SQLite for the database and Caddy for HTTPS at <code>rss.example.com</code>.</p>
<p>The container’s Apache listener is available only at <code>127.0.0.1:8080</code>. Caddy is the public entry point, while the image’s built-in cron runs feed refreshes twice an hour. The verification path adds a real Atom feed, runs the updater, marks an article read, restarts the container, and checks that both the subscription and read state survived.</p>
<p>FreshRSS is one option in our broader guide to <a href="https://sa.net/blog/best-self-hosted-apps/" title="self-hosted apps for a VPS">self-hosted apps for a VPS</a>. It makes a good first service; just plan on maintaining TLS, updates, and backups after the novelty wears off.</p>
<h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need:</p>
<ul>
<li>a Debian or Ubuntu VPS with root or sudo access;</li>
<li>an A record for <code>rss.example.com</code> pointing to the VPS, plus an AAAA record only if IPv6 is configured and filtered correctly;</li>
<li>TCP 80 and 443 open for Caddy and certificate issuance;</li>
<li>a modern browser and an off-server backup destination.</li>
</ul>
<p>The SQLite path in this tutorial is simple and complete for a single VPS. FreshRSS also supports PostgreSQL and MySQL/MariaDB, but either choice adds a separate database service, credentials, upgrades, and backup scope. No public database port is needed.</p>
<p>Secure the host’s administrative login before deploying the reader. Our <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="SSH hardening guide">SSH hardening guide</a> explains how to add and test keys before disabling passwords.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script (read it first if piping a script into root bothers you, or use Docker’s apt repository instead):</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> bash</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>Install Caddy and UFW from the Debian or Ubuntu repository. Caddy also publishes <a href="https://caddyserver.com/docs/install" title="official package instructions" target="_blank" rel="noopener noreferrer">official package instructions</a> for distributions that need its upstream repository.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> ufw</span></span></code></pre>
<p>This firewall example assumes administrative SSH uses TCP 22:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> verbose</span></span></code></pre>
<p>Replace port 22 if you moved SSH. Apply the same public inbound rules to the provider’s cloud firewall or security group. Port 8080 must not be public.</p>
<h2 id="create-the-compose-deployment">Create the Compose deployment<a href="#create-the-compose-deployment" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Store the deployment under <code>/opt/freshrss</code>. Bind mounts make the data and extensions easy to inspect, archive, and restore without relying on Docker’s volume-name conventions.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#9ECBFF"> /opt/freshrss/data</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#9ECBFF"> /opt/freshrss/extensions</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span></code></pre>
<p>Create <code>/opt/freshrss/compose.yml</code>. It follows FreshRSS’s official Docker examples; the release pin, loopback binding, and staggered cron schedule are our choices.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> compose.yml</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;YAML&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">services:</span></span>
<span class="line"><span style="color:#9ECBFF">  freshrss:</span></span>
<span class="line"><span style="color:#9ECBFF">    image: freshrss/freshrss:1.29.1</span></span>
<span class="line"><span style="color:#9ECBFF">    container_name: freshrss</span></span>
<span class="line"><span style="color:#9ECBFF">    restart: unless-stopped</span></span>
<span class="line"><span style="color:#9ECBFF">    environment:</span></span>
<span class="line"><span style="color:#9ECBFF">      TZ: &quot;UTC&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      CRON_MIN: &quot;13,43&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    volumes:</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./data:/var/www/FreshRSS/data</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./extensions:/var/www/FreshRSS/extensions</span></span>
<span class="line"><span style="color:#9ECBFF">    ports:</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;127.0.0.1:8080:80&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    logging:</span></span>
<span class="line"><span style="color:#9ECBFF">      options:</span></span>
<span class="line"><span style="color:#9ECBFF">        max-size: &quot;10m&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">        max-file: &quot;3&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    healthcheck:</span></span>
<span class="line"><span style="color:#9ECBFF">      test: [&quot;CMD&quot;, &quot;cli/health.php&quot;]</span></span>
<span class="line"><span style="color:#9ECBFF">      timeout: 10s</span></span>
<span class="line"><span style="color:#9ECBFF">      start_period: 60s</span></span>
<span class="line"><span style="color:#9ECBFF">      interval: 75s</span></span>
<span class="line"><span style="color:#9ECBFF">      retries: 3</span></span>
<span class="line"><span style="color:#9ECBFF">YAML</span></span></code></pre>
<p>FreshRSS listens on port 80 inside this image. Docker maps it to loopback port 8080, where only host processes such as Caddy can connect. <code>/var/www/FreshRSS/data</code> contains configuration, user data, and the SQLite databases. The extensions mount preserves any third-party extensions installed later.</p>
<p><code>TZ=UTC</code> makes log and cron times unambiguous. <code>CRON_MIN=13,43</code> asks the image’s scheduled job to run at 13 and 43 minutes past each UTC hour. The stagger avoids the common top-of-hour spike. FreshRSS’s <a href="https://freshrss.github.io/FreshRSS/en/admins/08_FeedUpdates.html" title="feed-update documentation" target="_blank" rel="noopener noreferrer">feed-update documentation</a> notes that scheduling much more often than every 20 minutes brings little benefit because individual feeds are not refreshed more frequently than that.</p>
<p>Pinning <code>1.29.1</code> keeps the deployment reproducible. It also requires you to read the <a href="https://github.com/FreshRSS/FreshRSS/releases" title="FreshRSS release notes" target="_blank" rel="noopener noreferrer">FreshRSS release notes</a> and deliberately change the tag when you are ready to update. Avoid the rolling <code>edge</code> tag for a routine production install.</p>
<p>Start the service:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> freshrss</span></span></code></pre>
<p>Confirm that Apache is not exposed on every interface:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:8080&#39;</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> http://127.0.0.1:8080/</span></span></code></pre>
<p>The listener should show <code>127.0.0.1:8080</code>, not <code>0.0.0.0:8080</code>.</p>
<h2 id="complete-the-web-installer-through-an-ssh-tunnel">Complete the web installer through an SSH tunnel<a href="#complete-the-web-installer-through-an-ssh-tunnel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Do not add the public Caddy route yet. The uninitialized web installer creates the first user, so exposing it before setup gives an Internet visitor a chance to claim the instance. Complete it through a private SSH tunnel instead.</p>
<p>From your workstation, open a tunnel and leave it running:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 8080:127.0.0.1:8080</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:8080</code>. The <a href="https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/README.md" title="official Docker guide" target="_blank" rel="noopener noreferrer">official Docker guide</a> recommends the web installation path used here.</p>
<ol>
<li>Choose the interface language and continue after the prerequisite check passes.</li>
<li>Select <strong>SQLite</strong> as the database.</li>
<li>Set the application or base URL to <code>https://rss.example.com</code>.</li>
<li>Create the initial user with a strong, unique password.</li>
<li>Select form authentication and finish the installation.</li>
<li>Log in through the tunnel.</li>
</ol>
<p>Never select <strong>No authentication</strong> for a reader reachable from the Internet. FreshRSS’s access-control documentation calls that mode dangerous on a public server.</p>
<p>The Compose file contains no administrator password because the browser setup writes it directly into the persistent data directory. The official image also supports unattended environment variables, but those variables apply only during first initialization and make secret handling easier to get wrong.</p>
<p>Before public exposure, confirm the initial user exists:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#9ECBFF"> cli/list-users.php</span></span></code></pre>
<p>Sign out through the tunneled browser, reload the root page, and visit <code>http://127.0.0.1:8080/i/</code>. The root must show the login flow, and <code>/i/</code> must not offer a fresh installation wizard. Stop and inspect the persistent <code>data</code> directory if the installer is still available. Close the tunnel only after those checks pass.</p>
<h2 id="configure-caddy-and-tls">Configure Caddy and TLS<a href="#configure-caddy-and-tls" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Now expose the initialized login page through Caddy. Use an imported site snippet so this tutorial does not overwrite other hosts already served by Caddy:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/freshrss.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">rss.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:8080</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> https://rss.example.com/</span></span></code></pre>
<p>Caddy obtains and renews a public certificate when DNS points to this VPS and TCP 80/443 can reach it. If the certificate does not appear, check the DNS records, UFW, the provider firewall, and <code>sudo journalctl -u caddy -n 100 --no-pager</code>.</p>
<p>Do not broaden FreshRSS’s <code>TRUSTED_PROXY</code> setting just to make the page load. It affects which forwarded client addresses and authentication headers FreshRSS trusts. This host-proxy design works without adding a broad Docker-network range. If a later authentication integration requires the setting, trust only the actual proxy path.</p>
<h2 id="add-a-feed-and-prove-state-persists">Add a feed and prove state persists<a href="#add-a-feed-and-prove-state-persists" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Log in at <code>https://rss.example.com</code>. Choose <strong>Add a feed</strong> and enter the public FreshRSS releases feed:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>https://github.com/FreshRSS/FreshRSS/releases.atom</span></span></code></pre>
<p>Trigger an immediate server-side refresh rather than waiting for minute 13 or 43:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  php</span><span style="color:#9ECBFF"> /var/www/FreshRSS/app/actualize_script.php</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --since=5m</span><span style="color:#9ECBFF"> freshrss</span></span></code></pre>
<p>Reload the browser and confirm release entries appear. Open one item, mark it read, and optionally mark it as a favorite. Then restart the container:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#9ECBFF"> cli/health.php</span></span></code></pre>
<p>Log in again. The subscription, its articles, and the read or favorite state should all have survived, which exercises the browser workflow, updater, SQLite data, and bind mount together. After minute 13 or 43 passes, check <code>sudo docker compose logs --since=45m freshrss</code> to confirm cron ran.</p>
<p>If you use a mobile reader, enable API access in the user settings and set a separate API password. Test login and article-state sync from that client. Do not assume the normal web password is the API credential.</p>
<h2 id="use-opml-for-migration-not-recovery">Use OPML for migration, not recovery<a href="#use-opml-for-migration-not-recovery" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>FreshRSS can import and export subscriptions as OPML from <strong>Subscription management &gt; Import/export</strong>. Export an OPML file after initial setup and whenever the subscription list changes substantially. It is useful for moving feeds to another reader or rebuilding the list by hand.</p>
<p>OPML is not a full backup. It does not preserve the whole FreshRSS configuration, read and favorite state, user credentials, extensions, or every feed-specific setting. Keep it beside, not instead of, the application backup.</p>
<h2 id="back-up-data-sqlite-and-extensions">Back up data, SQLite, and extensions<a href="#back-up-data-sqlite-and-extensions" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>FreshRSS’s <a href="https://freshrss.github.io/FreshRSS/en/admins/05_Backup.html" title="backup guide" target="_blank" rel="noopener noreferrer">backup guide</a> provides a portable per-user SQLite export:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#9ECBFF"> cli/db-backup.php</span></span></code></pre>
<p>The command writes the export under persistent user data, so it still needs to be copied off the server. For full state, archive both bind mounts with the container stopped:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /var/backups/freshrss</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/freshrss</span><span style="color:#79B8FF"> -czf</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;/var/backups/freshrss/freshrss-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  data</span><span style="color:#9ECBFF"> extensions</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span></code></pre>
<p>Encrypt the archive and copy it to storage outside the VPS. If you adopt PostgreSQL or MySQL/MariaDB, include a native <code>pg_dump</code> or <code>mysqldump</code> from the same recovery point.</p>
<p>Test the archive on an isolated VPS with the same image tag. Transfer one archive, set <code>BACKUP</code> to its real path, and keep the empty directories as rollback points:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> BACKUP</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">/path/to/freshrss-YYYY-MM-DD-HHMMSS.tar.gz</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mv</span><span style="color:#9ECBFF"> data</span><span style="color:#9ECBFF"> data.empty</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mv</span><span style="color:#9ECBFF"> extensions</span><span style="color:#9ECBFF"> extensions.empty</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/freshrss</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#9ECBFF"> cli/health.php</span></span></code></pre>
<p>Log in, refresh the test feed, and confirm the subscription and read state. Test each installed extension too.</p>
<p>For a portable database export, the official restore command is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  cli/db-restore.php</span><span style="color:#79B8FF"> --delete-backup</span><span style="color:#79B8FF"> --force-overwrite</span></span></code></pre>
<p>Use it only against the intended test or recovery instance after reading the prompts and backup documentation.</p>
<p>Every Riven Cloud plan includes daily backups, and they make a good safety net, but they do not replace an application-consistent backup and a rehearsed restore.</p>
<h2 id="upgrade-the-container">Upgrade the container<a href="#upgrade-the-container" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the release notes, export the user database, and take the stopped-container archive. Change only the exact image tag in <code>compose.yml</code>, then run:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/freshrss</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> --user</span><span style="color:#9ECBFF"> www-data</span><span style="color:#9ECBFF"> freshrss</span><span style="color:#9ECBFF"> cli/health.php</span></span></code></pre>
<p>Log in, run <code>/var/www/FreshRSS/app/actualize_script.php</code>, and confirm feed and read-state behavior. Docker deployments should replace the image rather than use the web updater. Treat a PostgreSQL major-version change as a separate database migration.</p>
<h2 id="choosing-a-riven-cloud-vps-for-freshrss">Choosing a Riven Cloud VPS for FreshRSS<a href="#choosing-a-riven-cloud-vps-for-freshrss" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a private reader on SQLite, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable fit. Feed count, refresh frequency, full-text retrieval, and retention decide how far it stretches; if you add PostgreSQL, RSS-Bridge, heavy image proxying, or browser-based scraping, our Ultra plan (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) is the sensible step up. Every plan is an unmanaged KVM VPS with full root access, a 1 Gbps port, and daily backups, so you run the stack yourself and keep full control.</p>
<p>FreshRSS talks both to its readers and to feed publishers, so location affects two paths. Choose Tokyo or Singapore based on where you read from, and check that your important feeds respond well from that data-center IP (more RAM will not fix a publisher that blocks data-center traffic). If you read from mainland China, a quick comparison of the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own access network will show which location feels faster. Once you have a rough idea of the workload, the details are on the <a href="https://sa.net/pricing/" title="Riven Cloud VPS plans">Riven Cloud VPS plans</a> page.</p>
<h2 id="security-limits">Security limits<a href="#security-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>FreshRSS fetches user-supplied feed URLs from the server, which creates server-side request forgery risk on shared or multi-user instances. As of July 16, 2026, release 1.29.1 does not restrict fetching internal hosts, so a feed URL can reach loopback or LAN services the container can route to. Rely on network-level controls instead: the compose and network layout in this guide, the firewall, and keeping cloud metadata endpoints and sensitive internal services out of the container’s reach. The next release after 1.29.1 adds default internal-host blocking with an <code>INTERNAL_HOST_ALLOWLIST</code> allowlist (<a href="https://github.com/FreshRSS/FreshRSS/pull/8400" title="FreshRSS PR #8400" target="_blank" rel="noopener noreferrer">FreshRSS PR #8400</a>), so revisit this section after upgrading; at that point, allow only the exact host, port, or network range a private RSS-Bridge or RSSHub needs, and never set the allowlist to <code>*</code>.</p>
<p>Install extensions only from sources you trust because they execute server-side code. Protect private feed URLs, feed credentials, browser sessions, and backups. Keep Apache’s port on loopback and apply host and container updates promptly.</p>
<h2 id="when-not-to-self-host">When not to self-host<a href="#when-not-to-self-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A hosted reader is the better choice if nobody will own TLS, feed scheduling, updates, monitoring, and restore tests. A public reader for untrusted users also brings SSRF, privacy, abuse, and transfer concerns that a personal instance largely avoids, and a managed service is the safer fit when private feed credentials need formal controls or reader availability needs a commercial SLA.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host Umami with Docker]]></title>
        <id>https://sa.net/blog/self-host-umami/</id>
        <link href="https://sa.net/blog/self-host-umami/"/>
        <updated>2026-07-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy Umami 3.2.0 with Docker Compose, PostgreSQL, and Caddy TLS, record a real pageview, and verify both backups and upgrades.]]></summary>
        <content type="html"><![CDATA[<p>Umami Analytics can keep website analytics and dashboard accounts in PostgreSQL on a VPS you control. A production installation still has several moving parts: the tracker must load over HTTPS, PostgreSQL must remain private and persistent, authentication needs a stable secret, and upgrades may run database migrations.</p>
<p>This tutorial installs the current Umami 3.2.0 release on Debian or Ubuntu with Docker Compose. Umami listens only on <code>127.0.0.1:3000</code>; PostgreSQL has no host port at all. Caddy or Nginx terminates TLS at <code>analytics.example.com</code>.</p>
<p>This deployment pins the application image to <code>ghcr.io/umami-software/umami:3.2.0</code>, the newest release when checked on July 16, 2026. Umami publishes plain version tags on GHCR (<code>3</code>, <code>3.2</code>, <code>3.2.0</code>, and so on) alongside <code>latest</code>, so pinning the exact release keeps upgrades deliberate instead of accidental. The database follows the official Compose file’s choice of <code>postgres:15-alpine</code>.</p>
<h2 id="prerequisites-prepare-docker-dns-and-the-firewall">Prerequisites: prepare Docker, DNS, and the firewall<a href="#prerequisites-prepare-docker-dns-and-the-firewall" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need an unmanaged Linux VPS with root or sudo access, Docker Engine, the Compose plugin, and a DNS A record that points <code>analytics.example.com</code> to the VPS. Publish an AAAA record only when the complete HTTPS path works over IPv6. Umami requires PostgreSQL 12.14 or newer; this deployment follows the official Compose choice of the PostgreSQL 15 Alpine series and configures it for UTC.</p>
<p>Our VPS plans are unmanaged, so you run the containers, database, firewall, backups, and upgrades yourself and keep full control. If you are comparing several services for one server, our <a href="https://sa.net/blog/best-self-hosted-apps/" title="self-hosted apps guide">self-hosted apps guide</a> is a good place to start.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script; it is worth skimming the script before running it so you know what it changes:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sh</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>The script runs as root and adds Docker’s package repository. If that does not suit your host, use your distribution’s own Docker packages instead. The commands below assume your user can run Docker. Otherwise prefix them with <code>sudo</code> or follow Docker’s documented post-install procedure.</p>
<p>If UFW is active, allow HTTP and HTTPS. Keep the current SSH rule in place:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>Apply equivalent rules at the hosting-provider firewall. Do not expose ports 3000 or 5432. Confirm DNS with <code>getent ahosts analytics.example.com</code> before requesting a certificate.</p>
<h2 id="generate-the-environment-file">Generate the environment file<a href="#generate-the-environment-file" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create the project directory:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/umami</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/umami</span></span></code></pre>
<p>Generate URL-safe random values without printing them to the terminal. Hex keeps the PostgreSQL password valid inside <code>DATABASE_URL</code> without percent-encoding:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;POSTGRES_PASSWORD=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-hex</span><span style="color:#79B8FF"> 24</span><span style="color:#9ECBFF">)&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> .env</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;APP_SECRET=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-hex</span><span style="color:#79B8FF"> 32</span><span style="color:#9ECBFF">)&quot;</span><span style="color:#F97583"> &gt;&gt;</span><span style="color:#9ECBFF"> .env</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> .env</span></span></code></pre>
<p>Do not paste the contents of <code>.env</code> into tickets or logs. <code>DATABASE_URL</code> tells Umami which PostgreSQL user, password, database, host, and port to use. In Compose, the hostname is the service name <code>db</code>, not <code>localhost</code>. <code>APP_SECRET</code> is a unique random value used to secure authentication tokens. Keep it unchanged across restarts and restores; changing it invalidates existing login sessions.</p>
<h2 id="create-the-umami-and-postgresql-services">Create the Umami and PostgreSQL services<a href="#create-the-umami-and-postgresql-services" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create <code>/opt/umami/compose.yaml</code>. It is based on Umami’s official Compose file; the loopback-only publication, required <code>APP_SECRET</code>, and bounded health waits are choices made for this tutorial.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  umami</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ghcr.io/umami-software/umami:3.2.0</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:3000:3000&quot;</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      DATABASE_URL</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;postgresql://umami:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/umami&quot;</span></span>
<span class="line"><span style="color:#85E89D">      APP_SECRET</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${APP_SECRET:?set APP_SECRET}&quot;</span></span>
<span class="line"><span style="color:#85E89D">    depends_on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        condition</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">service_healthy</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">        [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;curl -fsS http://localhost:3000/api/heartbeat || exit 1&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">      start_period</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">30s</span></span>
<span class="line"><span style="color:#85E89D">    init</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">postgres:15-alpine</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_DB</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">umami</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_USER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">umami</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_PASSWORD</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      TZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">      PGTZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">umami-db-data:/var/lib/postgresql/data</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  umami-db-data</span><span style="color:#E1E4E8">:</span></span></code></pre>
<p>The <code>db</code> health check prevents Umami from starting before PostgreSQL accepts connections. All accounts, website definitions, shares, and analytics events live in the named <code>umami-db-data</code> volume. The application container is disposable and needs no data volume.</p>
<p>Start both services and check the heartbeat:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:3000/api/heartbeat</span></span></code></pre>
<p>If Umami loops during startup, read <code>docker compose logs umami</code> before changing the database. Common causes are a damaged <code>DATABASE_URL</code>, a missing <code>.env</code>, or PostgreSQL that never reached healthy state.</p>
<h2 id="change-the-default-password-through-an-ssh-tunnel">Change the default password through an SSH tunnel<a href="#change-the-default-password-through-an-ssh-tunnel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Do not enable the public Caddy route yet. On first initialization, Umami creates the documented account <code>admin</code> with password <code>umami</code>. Publishing that login page before changing the password creates an avoidable takeover window.</p>
<p>From your workstation, forward the loopback-only listener and leave the tunnel running:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 3000:127.0.0.1:3000</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:3000</code>, sign in as <code>admin</code> with password <code>umami</code>, and replace it with a strong, unique password. Sign out, confirm the old password no longer works, then sign in with the new one. Close the tunnel only after that check passes.</p>
<h2 id="serve-the-dashboard-and-tracker-over-https">Serve the dashboard and tracker over HTTPS<a href="#serve-the-dashboard-and-tracker-over-https" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>An HTTPS website will refuse to load an HTTP analytics script as mixed content. Put the dashboard, tracker, and collection endpoint behind the same valid TLS hostname.</p>
<p>With Caddy, add this block to <code>/etc/caddy/Caddyfile</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>analytics.example.com {</span></span>
<span class="line"><span>    reverse_proxy 127.0.0.1:3000</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Caddy handles certificate issuance and forwards the original host, client address, and scheme. Validate, reload, and check the public heartbeat:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://analytics.example.com/api/heartbeat</span></span></code></pre>
<p>For Nginx, terminate TLS on 443, redirect port 80, and proxy to <code>http://127.0.0.1:3000</code>. Preserve <code>Host</code>, append <code>X-Forwarded-For</code>, and set <code>X-Forwarded-Proto</code> to the original scheme. Umami does not need an invented <code>BASE_URL</code> variable for this deployment; the proxy’s host and forwarded headers describe the public request. The <a href="https://docs.umami.is/docs/install" title="Umami installation guide" target="_blank" rel="noopener noreferrer">Umami installation guide</a> links to Nginx’s reverse-proxy documentation for this pattern.</p>
<p>Do not put blanket authentication in front of the whole hostname. The tracker script and <code>/api/send</code> collection endpoint must remain reachable from tracked websites. If you add another access layer for the dashboard, test its path rules carefully.</p>
<h2 id="record-a-real-pageview">Record a real pageview<a href="#record-a-real-pageview" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Browse to <code>https://analytics.example.com</code> and sign in with the password set through the SSH tunnel.</p>
<p>Add a website in the dashboard. Use the actual site hostname and a recognizable name, then open its tracking-code section. Umami generates the website ID and the exact snippet. It has this form:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="html"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">script</span></span>
<span class="line"><span style="color:#B392F0">  defer</span></span>
<span class="line"><span style="color:#B392F0">  src</span><span style="color:#E1E4E8">=</span><span style="color:#9ECBFF">&quot;https://analytics.example.com/script.js&quot;</span></span>
<span class="line"><span style="color:#B392F0">  data-website-id</span><span style="color:#E1E4E8">=</span><span style="color:#9ECBFF">&quot;WEBSITE-ID-FROM-UMAMI&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">&gt;&lt;/</span><span style="color:#85E89D">script</span><span style="color:#E1E4E8">&gt;</span></span></code></pre>
<p>Copy the generated snippet into the tracked site’s <code>&lt;head&gt;</code>. Deploy that page and open it in a normal browser with tracker blocking disabled.</p>
<p>Use the browser’s Network panel to verify two requests: <code>https://analytics.example.com/script.js</code> loads successfully, and the collection request to <code>/api/send</code> returns a successful status. The <a href="https://docs.umami.is/docs/collect-data" title="Umami collection guide" target="_blank" rel="noopener noreferrer">Umami collection guide</a> says the visit should appear immediately. Confirm the pageview in the dashboard’s realtime view, then repeat from another browser or device. This is the end-to-end test; a green container health check alone proves nothing about tracker delivery.</p>
<p>If the pageview does not appear, check the generated website ID, browser blockers, Content Security Policy, mixed-content errors, reverse-proxy logs, and Umami logs:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=100</span><span style="color:#9ECBFF"> umami</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> https://analytics.example.com/script.js</span></span></code></pre>
<h2 id="back-up-postgresql-and-configuration">Back up PostgreSQL and configuration<a href="#back-up-postgresql-and-configuration" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Umami’s documentation has no application-specific backup procedure, so use PostgreSQL’s own tools. Use <code>pg_dump</code> rather than copying a live database volume; the custom format supports a controlled <code>pg_restore</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/umami</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> db</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  pg_dump</span><span style="color:#79B8FF"> -U</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> -Fc</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  &gt;</span><span style="color:#9ECBFF"> &quot;backups/umami-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F).dump&quot;</span></span>
<span class="line"><span style="color:#79B8FF">test</span><span style="color:#79B8FF"> -s</span><span style="color:#9ECBFF"> &quot;backups/umami-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F).dump&quot;</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;backups/umami-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F).dump&quot;</span></span></code></pre>
<p>Because <code>compose.yaml</code> pins exact image tags, the Compose file itself records which Umami and PostgreSQL versions produced the dump. Keep a copy of it with every dump and a restore can always use matching versions.</p>
<p>Preserve <code>compose.yaml</code>, the mode-600 <code>.env</code>, the reverse-proxy configuration, and any optional tracker endpoint settings in an encrypted, access-controlled backup. The dump contains analytics, users, teams, shares, and website definitions. The stable <code>APP_SECRET</code> and database password are configuration secrets, so do not store them in a public repository or beside an unencrypted public dump.</p>
<p>For the Caddy deployment in this guide, make a restricted configuration archive without displaying either secret:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -czf</span><span style="color:#9ECBFF"> &quot;backups/umami-config-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F).tar.gz&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  compose.yaml</span><span style="color:#9ECBFF"> .env</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;backups/umami-config-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F).tar.gz&quot;</span></span></code></pre>
<p>Use the corresponding Nginx site file if you chose Nginx. Encrypt the configuration archive before copying it off the VPS.</p>
<p>Riven Cloud plans include daily provider backups as an extra safety net, but nothing replaces an application-consistent PostgreSQL dump that you have rehearsed restoring. Copy encrypted backups off the VPS. Database growth follows event volume and retention, so keep an eye on the volume and decide how long raw analytics should stay available.</p>
<h2 id="restore-and-prove-the-backup-works">Restore and prove the backup works<a href="#restore-and-prove-the-backup-works" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>This restore drill uses a separate directory, Compose project, PostgreSQL volume, and loopback port. It never stops or drops the production database. Copy the production secrets and one dump into the isolated project without printing either one:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/umami-restore/backups</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/umami/.env</span><span style="color:#9ECBFF"> /opt/umami-restore/.env</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /path/to/umami-YYYY-MM-DD.dump</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/umami-restore/backups/umami-YYYY-MM-DD.dump</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/umami-restore</span></span></code></pre>
<p>Create <code>/opt/umami-restore/compose.yaml</code> with the same pinned images as the production Compose file, a distinct database volume, and non-conflicting loopback port 3300:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  umami</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ghcr.io/umami-software/umami:3.2.0</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:3300:3000&quot;</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      DATABASE_URL</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;postgresql://umami:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/umami&quot;</span></span>
<span class="line"><span style="color:#85E89D">      APP_SECRET</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${APP_SECRET:?set APP_SECRET}&quot;</span></span>
<span class="line"><span style="color:#85E89D">    depends_on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        condition</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">service_healthy</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">        [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;curl -fsS http://localhost:3000/api/heartbeat || exit 1&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">      start_period</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">30s</span></span>
<span class="line"><span style="color:#85E89D">    init</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">postgres:15-alpine</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_DB</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">umami</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_USER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">umami</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_PASSWORD</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      TZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">      PGTZ</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">umami-restore-db-data:/var/lib/postgresql/data</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  umami-restore-db-data</span><span style="color:#E1E4E8">:</span></span></code></pre>
<p>Start the isolated database. The new PostgreSQL volume contains a fresh, empty <code>umami</code> database, so no <code>dropdb</code> command is needed. Restore the dump, start the isolated application, and check its heartbeat:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/umami-restore</span></span>
<span class="line"><span style="color:#79B8FF">test</span><span style="color:#79B8FF"> -s</span><span style="color:#9ECBFF"> backups/umami-YYYY-MM-DD.dump</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> umami-restore</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span><span style="color:#9ECBFF"> db</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> umami-restore</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> db</span><span style="color:#9ECBFF"> pg_restore</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -U</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  &lt;</span><span style="color:#9ECBFF"> backups/umami-YYYY-MM-DD.dump</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> umami-restore</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span><span style="color:#9ECBFF"> umami</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:3300/api/heartbeat</span></span></code></pre>
<p>Inspect the restored dashboard without creating a public proxy route. From your workstation, open a second tunnel:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 3300:127.0.0.1:3300</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:3300</code>, log in with the restored account, and confirm the websites and historical reports exist. Keep production running while you compare a few known totals. Once a dump has passed this drill, you can rely on it.</p>
<h2 id="upgrade-umami-safely">Upgrade Umami safely<a href="#upgrade-umami-safely" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Umami updates can include database migrations. Read the <a href="https://github.com/umami-software/umami/releases" title="release notes" target="_blank" rel="noopener noreferrer">release notes</a> and <a href="https://docs.umami.is/docs/updates" title="official update guidance" target="_blank" rel="noopener noreferrer">official update guidance</a>, and take a fresh logical dump first. The image tag is pinned, so pulling alone changes nothing; edit <code>compose.yaml</code>, set the Umami image tag to the release you reviewed, then pull and recreate:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> umami</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://analytics.example.com/api/heartbeat</span></span></code></pre>
<p>Wait for startup and migration logs to settle. On major upgrades or large migrated databases, Umami recommends refreshing PostgreSQL planner statistics:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> db</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  psql</span><span style="color:#79B8FF"> -U</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> umami</span><span style="color:#79B8FF"> -c</span><span style="color:#9ECBFF"> &#39;ANALYZE;&#39;</span></span></code></pre>
<p>Log in, inspect an existing dashboard, load the tracker, and confirm a fresh pageview reaches <code>/api/send</code> and appears in realtime. Never use <code>docker compose down -v</code>; that removes the PostgreSQL volume.</p>
<h3 id="update-the-postgresql-15-image-separately">Update the PostgreSQL 15 image separately<a href="#update-the-postgresql-15-image-separately" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Keep database-image maintenance separate from an Umami application update, and take a fresh logical dump first. For a patch update within PostgreSQL major version 15:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/umami</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> db</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> umami</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 120</span><span style="color:#9ECBFF"> db</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span><span style="color:#9ECBFF"> umami</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://analytics.example.com/api/heartbeat</span></span></code></pre>
<p>Log in and confirm an existing dashboard plus a new pageview. Keep the pre-update dump until those checks pass. A PostgreSQL major-version change is a database migration, not an image patch; follow PostgreSQL’s major-upgrade or logical dump/restore procedure instead of using the sequence above.</p>
<h2 id="choosing-a-riven-cloud-vps-for-umami">Choosing a Riven Cloud VPS for Umami<a href="#choosing-a-riven-cloud-vps-for-umami" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For most sites, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a comfortable starting point for Umami. Capacity follows event volume, retention, and report queries more than the number of tracked domains, so if the database grows or reports slow down, Ultra (8 GB RAM, 80 GB NVMe, 2 TB transfer) and Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) give you room to move up. A periodic look at PostgreSQL size, ingestion rate, and backup time will tell you when it is time.</p>
<p>One placement tip: put the collector near the visitors and the sites that send events. Latency for whoever opens the dashboard matters far less than latency on the tracking path. We offer Tokyo and Singapore on every plan, and if a meaningful share of your traffic comes from mainland China, comparing the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from the relevant carriers at representative times is the quickest way to decide. The <a href="https://sa.net/pricing/" title="pricing page">pricing page</a> has the current plan details.</p>
<h2 id="security-privacy-and-when-not-to-self-host">Security, privacy, and when not to self-host<a href="#security-privacy-and-when-not-to-self-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep PostgreSQL private, retain the same <code>APP_SECRET</code>, patch both images, restrict backup access, and disable dashboard share URLs unless they are intentional. The collection endpoint is public by design. Anyone who obtains a website ID can submit fake events, so watch for report poisoning and disk growth. Apply proxy rate limits cautiously because a blunt limit can discard legitimate traffic bursts.</p>
<p>Analytics may involve consent, retention, deletion, data-location, or employee-monitoring obligations. IP-derived location and session identifiers may be personal data under applicable law. Review the current v3 session replay and heatmap features before enabling them because replay can capture sensitive page content or interactions. Umami sends anonymous product telemetry by default; the documented <code>DISABLE_TELEMETRY=1</code> option disables it if your policy requires that.</p>
<p>Umami Cloud or another hosted analytics service is the better choice when nobody can own PostgreSQL backups, restores, migrations, monitoring, and privacy requests, or when you need vendor support, high availability, or a formal SLA. If basic request counts from web-server logs answer the question, a simpler single-binary log analyzer avoids a database, a tracker endpoint, and application migrations. Self-hosted Umami fits when its dashboard and event model are worth that ongoing operational work to you.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host Paperless-ngx with Docker]]></title>
        <id>https://sa.net/blog/self-host-paperless-ngx/</id>
        <link href="https://sa.net/blog/self-host-paperless-ngx/"/>
        <updated>2026-07-15T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy Paperless-ngx 2.20.15 with PostgreSQL and Caddy TLS, test OCR on real documents, and practice an isolated restore before the archive grows.]]></summary>
        <content type="html"><![CDATA[<p>Paperless-ngx turns scans and PDFs into a searchable document archive. This tutorial deploys version 2.20.15 with Docker Compose on a fresh Debian or Ubuntu VPS, with PostgreSQL 18, Redis 8, and Caddy. The application port is published only on <code>127.0.0.1:8000</code>, and the first superuser is created from the command line before Caddy exposes the site. Paperless-ngx is licensed under <a href="https://github.com/paperless-ngx/paperless-ngx/blob/main/LICENSE" title="GPL-3.0" target="_blank" rel="noopener noreferrer">GPL-3.0</a>.</p>
<p>Version 2.20.15 was the current stable release when this article was checked on July 15, 2026. It fixes <a href="https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-8c6x-pfjq-9gr7" title="GHSA-8c6x-pfjq-9gr7" target="_blank" rel="noopener noreferrer">GHSA-8c6x-pfjq-9gr7</a>, a low-severity flaw that let non-superusers delete superusers (releases before 2.20.14 are affected, 2.20.15 has the fix), and upstream recommends that all users upgrade. Read the <a href="https://github.com/paperless-ngx/paperless-ngx/releases/tag/v2.20.15" title="2.20.15 release notes" target="_blank" rel="noopener noreferrer">2.20.15 release notes</a> and take a tested backup before upgrading.</p>
<p>A self-hosted Paperless-ngx server is an unmanaged deployment, so you run the operating system, TLS, access control, updates, monitoring, and recovery yourself and keep full control of the stack. Worth knowing up front: Paperless does not encrypt documents, extracted text, or filenames at rest. HTTPS protects traffic in transit; storage and backup encryption are separate decisions.</p>
<h2 id="prepare-the-vps-dns-and-firewall">Prepare the VPS, DNS, and firewall<a href="#prepare-the-vps-dns-and-firewall" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need a VPS with root or sudo access, an A record for <code>paperless.example.com</code>, and off-server backup storage. Add an AAAA record only if IPv6 works through the host firewall and Caddy. Allow TCP 80 and 443 for HTTPS, plus the port used by administrative SSH. PostgreSQL, Redis, and Paperless port 8000 must never be public.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script (or your distribution’s packages, if you’d rather not run a downloaded script as root):</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> bash</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>The remaining commands assume your account can run Docker. Otherwise, prefix each Docker command with <code>sudo</code> or follow Docker’s documented post-install procedure.</p>
<p>Install Caddy, UFW, and OpenSSL. Caddy also provides <a href="https://caddyserver.com/docs/install" title="official package instructions" target="_blank" rel="noopener noreferrer">official package instructions</a> if your distribution needs its repository.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> openssl</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> verbose</span></span></code></pre>
<p>Replace <code>22</code> before enabling UFW if SSH uses another port (our <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="SSH hardening guide">SSH hardening guide</a> explains how to move it safely), and mirror the rules in the provider firewall. Docker can bypass some host firewall paths when a port is published, which is another reason to keep the application binding explicitly on <code>127.0.0.1</code>.</p>
<h2 id="generate-secrets-without-printing-them">Generate secrets without printing them<a href="#generate-secrets-without-printing-them" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create the project and its two host directories. Paperless watches <code>consume</code> for incoming files and writes application exports under <code>export</code>.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/paperless/consume</span><span style="color:#9ECBFF"> /opt/paperless/export</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span></code></pre>
<p>Generate a 256-bit Paperless secret and a 192-bit PostgreSQL password without displaying either value:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;PAPERLESS_SECRET_KEY=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-hex</span><span style="color:#79B8FF"> 32</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;PAPERLESS_DB_PASSWORD=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-hex</span><span style="color:#79B8FF"> 24</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;USERMAP_UID=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">id</span><span style="color:#79B8FF"> -u</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;USERMAP_GID=%s\n&#39;</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">id</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">} </span><span style="color:#F97583">&gt;</span><span style="color:#E1E4E8"> .env</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> .env</span></span></code></pre>
<p>Keep <code>.env</code> unchanged across restarts and restores, and keep it out of tickets, terminal recordings, and Git. Paperless supports Docker secret files through <code>_FILE</code> variables if your secret-management system provides them; a mode-0600 file keeps this tutorial portable.</p>
<h2 id="create-the-paperless-compose-project">Create the Paperless Compose project<a href="#create-the-paperless-compose-project" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Create <code>/opt/paperless/compose.yaml</code> with the following content. It is a hardened single-VPS version of Paperless-ngx’s <a href="https://github.com/paperless-ngx/paperless-ngx/blob/v2.20.15/docker/compose/docker-compose.postgres.yml" title="official PostgreSQL Compose file" target="_blank" rel="noopener noreferrer">official PostgreSQL Compose file</a>. The service roles and mount points follow upstream; the random database password, loopback port, webhook restrictions, health dependencies, and log limits are tutorial choices.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  broker</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">redis:8</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">redisdata:/data</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;redis-cli&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;ping&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">5</span></span>
<span class="line"><span style="color:#85E89D">    logging</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      options</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        max-size</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;10m&quot;</span></span>
<span class="line"><span style="color:#85E89D">        max-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">postgres:18</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_DB</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">paperless</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_USER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">paperless</span></span>
<span class="line"><span style="color:#85E89D">      POSTGRES_PASSWORD</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${PAPERLESS_DB_PASSWORD:?set PAPERLESS_DB_PASSWORD}&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">pgdata:/var/lib/postgresql</span></span>
<span class="line"><span style="color:#85E89D">    healthcheck</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      test</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;CMD-SHELL&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;pg_isready -U paperless -d paperless&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">      interval</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">10s</span></span>
<span class="line"><span style="color:#85E89D">      timeout</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">5s</span></span>
<span class="line"><span style="color:#85E89D">      retries</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">5</span></span>
<span class="line"><span style="color:#85E89D">    logging</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      options</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        max-size</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;10m&quot;</span></span>
<span class="line"><span style="color:#85E89D">        max-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">  webserver</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ghcr.io/paperless-ngx/paperless-ngx:2.20.15</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">unless-stopped</span></span>
<span class="line"><span style="color:#85E89D">    depends_on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      broker</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        condition</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">service_healthy</span></span>
<span class="line"><span style="color:#85E89D">      db</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        condition</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">service_healthy</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:${PAPERLESS_HOST_PORT:-8000}:8000&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">data:/usr/src/paperless/data</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">media:/usr/src/paperless/media</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./export:/usr/src/paperless/export</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./consume:/usr/src/paperless/consume</span></span>
<span class="line"><span style="color:#85E89D">    environment</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_REDIS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">redis://broker:6379</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_DBHOST</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">db</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_DBNAME</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">paperless</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_DBUSER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">paperless</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_DBPASS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${PAPERLESS_DB_PASSWORD:?set PAPERLESS_DB_PASSWORD}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_URL</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${PAPERLESS_EXTERNAL_URL:-https://paperless.example.com}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_ALLOWED_HOSTS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${PAPERLESS_ALLOWED_HOSTS:-paperless.example.com}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_SECRET_KEY</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${PAPERLESS_SECRET_KEY:?set PAPERLESS_SECRET_KEY}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_TIME_ZONE</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">UTC</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_OCR_LANGUAGE</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">eng</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_TASK_WORKERS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;1&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_THREADS_PER_WORKER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;1&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_PROXY_SSL_HEADER</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&#39;[&quot;HTTP_X_FORWARDED_PROTO&quot;, &quot;https&quot;]&#39;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_WEBHOOKS_ALLOWED_PORTS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;80,443&quot;</span></span>
<span class="line"><span style="color:#85E89D">      PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;false&quot;</span></span>
<span class="line"><span style="color:#85E89D">      USERMAP_UID</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${USERMAP_UID:?set USERMAP_UID}&quot;</span></span>
<span class="line"><span style="color:#85E89D">      USERMAP_GID</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;${USERMAP_GID:?set USERMAP_GID}&quot;</span></span>
<span class="line"><span style="color:#85E89D">    logging</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      options</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">        max-size</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;10m&quot;</span></span>
<span class="line"><span style="color:#85E89D">        max-file</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  data</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  media</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  pgdata</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  redisdata</span><span style="color:#E1E4E8">:</span></span></code></pre>
<p>Replace every <code>paperless.example.com</code> before starting. <code>PAPERLESS_URL</code> must have no trailing slash or path. The explicit allowed host removes the insecure public default of <code>*</code>. Caddy overwrites <code>X-Forwarded-Proto</code>, and only the loopback proxy can reach port 8000, so trusting that header is appropriate for this layout. PostgreSQL and Redis use the major-version tags from the upstream 2.20.15 template, so they pick up patch releases automatically.</p>
<p>Start the private stack and check the listener:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> http://127.0.0.1:8000/</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:8000&#39;</span></span></code></pre>
<p>The listener must show <code>127.0.0.1:8000</code>, not <code>0.0.0.0:8000</code>.</p>
<h2 id="create-the-superuser-before-public-access">Create the superuser before public access<a href="#create-the-superuser-before-public-access" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Paperless can create an administrator from environment variables, but that leaves a plaintext password in long-lived configuration. Use the interactive management command from the <a href="https://docs.paperless-ngx.com/setup/" title="official setup documentation" target="_blank" rel="noopener noreferrer">official setup documentation</a> instead:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> webserver</span><span style="color:#9ECBFF"> createsuperuser</span></span></code></pre>
<p>Choose a non-obvious username, a real administrative email address, and a unique password from a password manager. The command hashes the password in the database and does not add it to <code>.env</code> or shell history. Do not add the public Caddy route until this command succeeds.</p>
<h2 id="put-caddy-in-front-of-paperless">Put Caddy in front of Paperless<a href="#put-caddy-in-front-of-paperless" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Paperless requires a TLS reverse proxy for Internet exposure. Its interface uses WebSocket connections, and uploads can exceed the small request-body defaults in some proxy examples. Caddy handles WebSocket upgrades and the standard forwarded headers automatically. Keep this site in a separate imported file so adding Paperless does not erase existing hosts. This block adds an explicit 100 MB proxy-side upload ceiling:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/paperless.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">paperless.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    request_body {</span></span>
<span class="line"><span style="color:#9ECBFF">        max_size 100MB</span></span>
<span class="line"><span style="color:#9ECBFF">    }</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:8000</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> https://paperless.example.com/</span></span></code></pre>
<p>Change the ceiling to match the largest document you intend to accept. A <code>413 Request Entity Too Large</code> points to the proxy or an application upload limit, while a stalled interface can mean broken WebSocket forwarding. Nginx users must explicitly configure HTTP/1.1 upgrade headers and raise <code>client_max_body_size</code>; a bare <code>proxy_pass</code> is incomplete.</p>
<p>Sign in over HTTPS with the CLI-created account. Enable multi-factor authentication if your identity setup supports it, and do not create broad user accounts merely to feed the consume directory.</p>
<h2 id="verify-a-real-upload-and-ocr-result">Verify a real upload and OCR result<a href="#verify-a-real-upload-and-ocr-result" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A green health check only proves that the web process answers HTTP. Test the workflow with a harmless image-only scan that contains a unique phrase, such as <code>paperless ocr test 20260714</code>. A phone photograph saved as JPEG works, and it forces Paperless to run OCR rather than read an existing PDF text layer.</p>
<p>Upload the image from the web interface. Watch <strong>Tasks</strong> until consumption finishes, then open the document and confirm all of the following:</p>
<ol>
<li>The original image opens and downloads.</li>
<li>The document page contains extracted text with the unique phrase.</li>
<li>Searching for that phrase returns the document.</li>
<li>The archived representation and thumbnail render correctly.</li>
</ol>
<p>If processing fails, read the consumer and OCR output before raising resource limits:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=250</span><span style="color:#9ECBFF"> webserver</span></span></code></pre>
<p>OCR is CPU and memory intensive. Multiple languages increase processing time, and untrusted PDFs or oversized images can exhaust resources or hit bugs in parsers such as Ghostscript and ImageMagick. Keep <code>PAPERLESS_TASK_WORKERS * PAPERLESS_THREADS_PER_WORKER</code> at or below the VPS’s logical CPU count. The conservative one-by-one setting above is intentional. Do not disable image-pixel safeguards or allow anonymous uploads.</p>
<h2 id="back-up-with-document_exporter">Back up with document_exporter<a href="#back-up-with-document_exporter" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Provider snapshots help with whole-host recovery, but a snapshot taken while PostgreSQL and Paperless are writing may not be application consistent. Paperless’s <a href="https://docs.paperless-ngx.com/administration/#backup" title="backup documentation" target="_blank" rel="noopener noreferrer">backup documentation</a> provides <code>document_exporter</code> to export documents, thumbnails, settings, and database content. API tokens are not included and must be recreated after recovery.</p>
<p>Pause watched mailboxes, scanners, and uploads, then wait for the Tasks view to become idle. Run the official exporter through Compose and checksum the result:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#E1E4E8">backup</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;backup-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S)&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> webserver</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  document_exporter</span><span style="color:#9ECBFF"> &quot;../export/</span><span style="color:#E1E4E8">$backup</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> --no-progress-bar</span></span>
<span class="line"><span style="color:#B392F0">find</span><span style="color:#9ECBFF"> &quot;export/</span><span style="color:#E1E4E8">$backup</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -type</span><span style="color:#9ECBFF"> f</span><span style="color:#79B8FF"> -print0</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  |</span><span style="color:#B392F0"> sort</span><span style="color:#79B8FF"> -z</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  |</span><span style="color:#B392F0"> xargs</span><span style="color:#79B8FF"> -0</span><span style="color:#79B8FF"> -r</span><span style="color:#9ECBFF"> sha256sum</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> &quot;export/</span><span style="color:#E1E4E8">$backup</span><span style="color:#9ECBFF">.sha256&quot;</span></span></code></pre>
<p>Record the Paperless version (2.20.15) alongside the export; the importer requires an exact version match. Copy the export, checksum, <code>compose.yaml</code>, Caddy configuration, and encrypted secret material off the VPS. The export contains the documents you were trying to protect, so encrypt it and restrict access at the destination. A copy on the same disk is not a backup.</p>
<h2 id="rehearse-an-isolated-same-version-restore">Rehearse an isolated same-version restore<a href="#rehearse-an-isolated-same-version-restore" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Paperless requires <code>document_importer</code> to run against an empty installation of exactly the same version. The drill below builds that empty installation beside production, in its own Compose project on an alternate loopback port. A separate test VPS is safer, since a same-host drill shares the Docker daemon, RAM, disk, and port space with production; if you use one, copy the Compose file, <code>.env</code>, and the latest export there first and adjust the source paths.</p>
<p>For a same-host drill, keep all commands in one shell:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">set</span><span style="color:#79B8FF"> -Eeuo</span><span style="color:#9ECBFF"> pipefail</span></span>
<span class="line"><span style="color:#E1E4E8">restore_project</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;paperless-restore-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%Y%m%d%H%M%S)&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">restore_dir</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;/opt/</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">backup_dir</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">find</span><span style="color:#9ECBFF"> /opt/paperless/export</span><span style="color:#79B8FF"> -mindepth</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> -maxdepth</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -type</span><span style="color:#9ECBFF"> d</span><span style="color:#79B8FF"> -name</span><span style="color:#9ECBFF"> &#39;backup-*&#39;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> tail</span><span style="color:#79B8FF"> -n</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">backup_name</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">basename</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$backup_dir</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0700</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/consume&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/export&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/paperless/.env</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/.env&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 0640</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /opt/paperless/compose.yaml</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/compose.yaml&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> cp</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$backup_dir</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/export/&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chown</span><span style="color:#79B8FF"> -R</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;:&quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> PAPERLESS_HOST_PORT</span><span style="color:#F97583">=</span><span style="color:#79B8FF">18000</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> PAPERLESS_EXTERNAL_URL</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">http://127.0.0.1:18000</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> PAPERLESS_ALLOWED_HOSTS</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">127.0.0.1,localhost</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> webserver</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  document_importer</span><span style="color:#9ECBFF"> &quot;../export/</span><span style="color:#E1E4E8">$backup_name</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> webserver</span><span style="color:#9ECBFF"> document_sanity_checker</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> http://127.0.0.1:18000/</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span></code></pre>
<p>The timestamped project name gives the drill its own containers, volumes, and network, so <code>document_importer</code> runs against a fresh, empty database. The importer still requires the export and target to use exactly the same Paperless version; an empty newer installation is not compatible.</p>
<p>Tunnel port 18000 over SSH, log in with a restored account, and inspect several originals, archived files, thumbnails, metadata, permissions, and searches. Recreate an API token if an integration needs one. The sanity checker catches internal inconsistencies, but it cannot prove that every document is readable or that an external integration still works.</p>
<p>After the rehearsal, remove the restore project and its isolated volumes:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> down</span><span style="color:#79B8FF"> -v</span></span></code></pre>
<p>Never run the importer over the production installation, and never use <code>down -v</code> in <code>/opt/paperless</code>; there it deletes the production volumes.</p>
<h2 id="upgrade-and-roll-back-deliberately">Upgrade and roll back deliberately<a href="#upgrade-and-roll-back-deliberately" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Before an upgrade, stop new ingestion, wait for active tasks, run <code>document_sanity_checker</code>, create a fresh exporter backup, and read every release note between the installed and target versions. Then update the version tag in <code>compose.yaml</code>.</p>
<p>Paperless’s official Docker procedure stops the stack, pulls, and starts it in the foreground so database migrations remain visible:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> webserver</span><span style="color:#9ECBFF"> document_sanity_checker</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> down</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span></span></code></pre>
<p>Watch for migration or worker errors. In another SSH session, test login, search, and a fresh OCR upload. Then stop the foreground run with Ctrl+C and return it to detached mode:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/paperless</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> webserver</span><span style="color:#9ECBFF"> document_sanity_checker</span></span></code></pre>
<p>Do not roll back by pointing an older image at a database migrated by a newer release. A safe rollback uses the pre-upgrade export with the old, exact Paperless version in a new empty deployment. Rehearse it on an alternate port, then switch Caddy only after documents, search, permissions, and OCR pass. PostgreSQL major upgrades need PostgreSQL’s own migration or a logical dump and restore; do not bundle them into a routine Paperless update.</p>
<h2 id="keep-tika-and-gotenberg-optional">Keep Tika and Gotenberg optional<a href="#keep-tika-and-gotenberg-optional" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The baseline handles the normal PDF and image workflow without Tika or Gotenberg. Enable the pair only if you need Office documents or email formats that require conversion. Paperless’s <a href="https://github.com/paperless-ngx/paperless-ngx/blob/v2.20.15/docker/compose/docker-compose.postgres-tika.yml" title="official Tika Compose template" target="_blank" rel="noopener noreferrer">official Tika Compose template</a> adds <code>apache/tika</code> and Gotenberg, then sets <code>PAPERLESS_TIKA_ENABLED=1</code> and internal endpoints for both services.</p>
<p>That template uses <code>gotenberg/gotenberg:8.25</code> and <code>apache/tika:latest</code>. The latter is a mutable tag; pin a specific version if you want repeatable deployments. Do not publish ports 3000 or 9998. Preserve the upstream Gotenberg arguments <code>--chromium-disable-javascript=true</code> and <code>--chromium-allow-list=file:///tmp/.*</code>; they reduce remote-content and JavaScript exposure while converting email. Tika and Gotenberg parse more untrusted formats and use more CPU and RAM, so they expand both the attack surface and the sizing requirement.</p>
<h2 id="choosing-a-riven-cloud-vps-for-paperless-ngx">Choosing a Riven Cloud VPS for Paperless-ngx<a href="#choosing-a-riven-cloud-vps-for-paperless-ngx" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a personal document archive with the conservative one-worker OCR settings above, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable starting point. Move up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) if you want faster OCR on a large backlog or plan to enable Tika and Gotenberg, and to Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) when the originals, archived files, thumbnails, and PostgreSQL start crowding the disk. Storage tends to grow long after the initial import, while an OCR backlog only needs CPU for a while, so watching disk usage usually tells you first when it is time to resize. If the archive will share its VPS with other services, our <a href="https://sa.net/blog/best-self-hosted-apps/" title="roundup of self-hosted apps">roundup of self-hosted apps</a> is a good place to gauge what co-hosts comfortably alongside an OCR workload.</p>
<p>All three plans include a 1 Gbps port, full root access, daily backups, and a choice of Tokyo or Singapore. The daily backups are a good safety net for whole-host recovery; keep the application-consistent exporter backups and the restore drill above in place as well. If you or your users connect from mainland China, take a moment to test the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own network before choosing a location, then compare the current <a href="https://sa.net/pricing/" title="Riven Cloud VPS plans">Riven Cloud VPS plans</a>.</p>
<h2 id="security-and-privacy-limits">Security and privacy limits<a href="#security-and-privacy-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Paperless removed its old built-in document encryption. Original and archived files are readable by the application, extracted document text is stored in plaintext in PostgreSQL, and filenames are not encrypted. Use an encrypted filesystem or encrypted block volume if you need protection from physical-media access, and always encrypt off-server exports. Access controls, TLS, and disk encryption solve different problems.</p>
<p>Workflow webhooks also deserve attention. Upstream defaults allow HTTP and HTTPS on any port and permit internal requests. The environment values above restrict ports to 80 and 443 and disable internal webhook requests, which reduces server-side request forgery risk. Treat those settings as one layer; container egress rules should also block cloud metadata and management networks, especially when less-trusted users can create workflows.</p>
<p>Do not accept uploads from anonymous or untrusted users. OCR and document parsers handle complex, attacker-controlled files and have no blanket sandbox guarantee from Paperless. Keep Paperless, the host, and every parser image patched; retain image-pixel limits; cap proxy upload size; and monitor CPU, memory, disk, and task queues.</p>
<p>Self-hosting Paperless-ngx works well when someone owns those controls, application-aware backups, restore tests, and security updates. If the archive needs built-in per-document encryption, contractual high availability, or formal records controls, a managed document system is the better home for it; a single unmanaged VPS is not designed to provide those guarantees.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host linkding with Docker]]></title>
        <id>https://sa.net/blog/self-host-linkding/</id>
        <link href="https://sa.net/blog/self-host-linkding/"/>
        <updated>2026-07-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy linkding 1.45.0 with Docker Compose and Caddy TLS, block private-network egress, and rehearse an isolated restore.]]></summary>
        <content type="html"><![CDATA[<p>linkding is a small self-hosted bookmark manager with tags, notes, sharing controls, a REST API, and optional page archiving. Its Docker Compose setup is simple, but linkding fetches bookmark URLs from the server, so an authenticated user can make the VPS contact other systems. The URL validation in 1.45.0 checks syntax only; it is not server-side request forgery protection.</p>
<p>This guide deploys linkding <a href="https://github.com/sissbruecker/linkding/releases/tag/v1.45.0" title="1.45.0" target="_blank" rel="noopener noreferrer">1.45.0</a> from the official <a href="https://hub.docker.com/r/sissbruecker/linkding/tags?name=1.45.0" title="Docker Hub image" target="_blank" rel="noopener noreferrer">Docker Hub image</a> on Debian or Ubuntu. Port 9090 stays bound to loopback, the first superuser is created before Caddy exposes the service, and host firewall rules block the container from private, link-local, and metadata address ranges.</p>
<p>Set up this way, linkding makes a pleasant private bookmark service for trusted users. The two habits worth keeping are treating the outbound fetcher as attack surface and testing your restores instead of assuming the copies work.</p>
<h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Prepare:</p>
<ul>
<li>a Debian or Ubuntu VPS with root or sudo access;</li>
<li>an A record for <code>bookmarks.example.com</code> pointing to the VPS, plus an AAAA record only when the complete IPv6 path is configured;</li>
<li>TCP 80 and 443 for Caddy and your existing administrative SSH port;</li>
<li>a trusted administrator account and no plan to offer open registration;</li>
<li>encrypted off-server backup storage;</li>
<li>an unused private Docker subnet. This guide uses <code>172.31.50.0/24</code>.</li>
</ul>
<p>Check that the example subnet does not overlap an existing host or container route:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ip</span><span style="color:#9ECBFF"> route</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> network</span><span style="color:#9ECBFF"> ls</span></span></code></pre>
<p>If it overlaps, choose another unused RFC1918 <code>/24</code> and replace both <code>172.31.50.0/24</code> and <code>172.31.50.10</code> everywhere below.</p>
<p>Install Docker Engine and the Compose plugin with Docker’s convenience script (skim it first if you want to know what it changes), then install the remaining packages:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sh</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> jq</span><span style="color:#9ECBFF"> unzip</span></span></code></pre>
<p>The script runs as root and adds Docker’s package repository; if that is unacceptable on your host, install Docker from your distribution’s packages instead.</p>
<p>Keep SSH allowed when configuring UFW. Replace port 22 if necessary:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span></code></pre>
<p>Apply the same inbound policy in any provider firewall. Port 9090 must never be public.</p>
<h2 id="create-the-linkding-compose-project">Create the linkding Compose project<a href="#create-the-linkding-compose-project" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The <a href="https://linkding.link/installation/" title="official installation guide" target="_blank" rel="noopener noreferrer">official installation guide</a> uses one container, SQLite, and a persistent <code>/etc/linkding/data</code> mount. Its data directory holds <code>db.sqlite3</code>, <code>secretkey.txt</code>, page assets, favicons, preview images, and background-task state.</p>
<p>Create the project, data, and backup directories:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 750</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#79B8FF">umask</span><span style="color:#79B8FF"> 077</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> data</span><span style="color:#9ECBFF"> backups</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#9ECBFF"> .env</span></span></code></pre>
<p>Create <code>/opt/linkding/.env</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">tee</span><span style="color:#9ECBFF"> .env</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;ENV&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">LD_DISABLE_URL_VALIDATION=False</span></span>
<span class="line"><span style="color:#9ECBFF">LD_CSRF_TRUSTED_ORIGINS=https://bookmarks.example.com</span></span>
<span class="line"><span style="color:#9ECBFF">LD_USE_X_FORWARDED_HOST=False</span></span>
<span class="line"><span style="color:#9ECBFF">LD_LOG_X_FORWARDED_FOR=False</span></span>
<span class="line"><span style="color:#9ECBFF">ENV</span></span>
<span class="line"><span style="color:#B392F0">chmod</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> .env</span></span></code></pre>
<p>Keeping <code>LD_DISABLE_URL_VALIDATION=False</code> preserves the normal HTTP/HTTPS syntax check. It does not make arbitrary destinations safe. <code>LD_USE_X_FORWARDED_HOST=False</code> avoids trusting a client-controlled forwarding header because Caddy preserves the ordinary <code>Host</code> header.</p>
<p>Create <code>/opt/linkding/compose.yaml</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">tee</span><span style="color:#9ECBFF"> compose.yaml</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;YAML&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">services:</span></span>
<span class="line"><span style="color:#9ECBFF">  linkding:</span></span>
<span class="line"><span style="color:#9ECBFF">    image: docker.io/sissbruecker/linkding:1.45.0</span></span>
<span class="line"><span style="color:#9ECBFF">    container_name: linkding</span></span>
<span class="line"><span style="color:#9ECBFF">    restart: unless-stopped</span></span>
<span class="line"><span style="color:#9ECBFF">    ports:</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;127.0.0.1:9090:9090&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    volumes:</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./data:/etc/linkding/data</span></span>
<span class="line"><span style="color:#9ECBFF">    env_file:</span></span>
<span class="line"><span style="color:#9ECBFF">      - .env</span></span>
<span class="line"><span style="color:#9ECBFF">    networks:</span></span>
<span class="line"><span style="color:#9ECBFF">      linkding_outbound:</span></span>
<span class="line"><span style="color:#9ECBFF">        ipv4_address: 172.31.50.10</span></span>
<span class="line"><span style="color:#9ECBFF">    logging:</span></span>
<span class="line"><span style="color:#9ECBFF">      options:</span></span>
<span class="line"><span style="color:#9ECBFF">        max-size: &quot;10m&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">        max-file: &quot;3&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ECBFF">networks:</span></span>
<span class="line"><span style="color:#9ECBFF">  linkding_outbound:</span></span>
<span class="line"><span style="color:#9ECBFF">    ipam:</span></span>
<span class="line"><span style="color:#9ECBFF">      config:</span></span>
<span class="line"><span style="color:#9ECBFF">        - subnet: 172.31.50.0/24</span></span>
<span class="line"><span style="color:#9ECBFF">YAML</span></span></code></pre>
<p>The custom network intentionally has no IPv6. Do not enable Docker IPv6 for this service until equivalent IPv6 egress rules block <code>::1</code>, <code>fc00::/7</code>, <code>fe80::/10</code>, and any provider-specific metadata destinations.</p>
<p>Start the container and check the listener:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:9090/health</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:9090&#39;</span></span></code></pre>
<p>The <a href="https://github.com/sissbruecker/linkding/blob/v1.45.0/bookmarks/views/health.py" title="official health endpoint" target="_blank" rel="noopener noreferrer">official health endpoint</a> checks the database connection and should return JSON with version <code>1.45.0</code> and status <code>healthy</code>. The host listener must be <code>127.0.0.1:9090</code>.</p>
<h2 id="block-private-and-metadata-egress">Block private and metadata egress<a href="#block-private-and-metadata-egress" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>linkding fetches page metadata with server-side HTTP requests. The plus image also runs Chromium and downloads PDF or HTML snapshots. The <a href="https://github.com/sissbruecker/linkding/blob/v1.45.0/bookmarks/validators.py" title="1.45.0 validator" target="_blank" rel="noopener noreferrer">1.45.0 validator</a> delegates to Django’s URL validator and does not reject loopback, RFC1918, link-local, cloud metadata, or DNS answers that resolve there.</p>
<p>Install persistent iptables support, then add rules scoped to the fixed container address. The first rule permits reply traffic for connections initiated toward the published application port. The remaining rules reject new container connections to non-public IPv4 ranges, including <code>169.254.169.254</code> within <code>169.254.0.0/16</code>.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> DEBIAN_FRONTEND=noninteractive</span><span style="color:#9ECBFF"> apt-get</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> iptables-persistent</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> iptables</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> DOCKER-USER</span><span style="color:#79B8FF"> -s</span><span style="color:#79B8FF"> 172.31.50.10</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -m</span><span style="color:#9ECBFF"> conntrack</span><span style="color:#79B8FF"> --ctstate</span><span style="color:#9ECBFF"> ESTABLISHED,RELATED</span><span style="color:#79B8FF"> -j</span><span style="color:#9ECBFF"> ACCEPT</span><span style="color:#F97583"> 2&gt;</span><span style="color:#9ECBFF">/dev/null</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">  ||</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> iptables</span><span style="color:#79B8FF"> -I</span><span style="color:#9ECBFF"> DOCKER-USER</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> -s</span><span style="color:#79B8FF"> 172.31.50.10</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -m</span><span style="color:#9ECBFF"> conntrack</span><span style="color:#79B8FF"> --ctstate</span><span style="color:#9ECBFF"> ESTABLISHED,RELATED</span><span style="color:#79B8FF"> -j</span><span style="color:#9ECBFF"> ACCEPT</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> cidr </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  0.0.0.0/8</span><span style="color:#9ECBFF"> 10.0.0.0/8</span><span style="color:#9ECBFF"> 100.64.0.0/10</span><span style="color:#9ECBFF"> 127.0.0.0/8</span><span style="color:#E1E4E8"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  169.254.0.0/16</span><span style="color:#9ECBFF"> 172.16.0.0/12</span><span style="color:#9ECBFF"> 192.0.0.0/24</span><span style="color:#E1E4E8"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  192.168.0.0/16</span><span style="color:#9ECBFF"> 198.18.0.0/15</span><span style="color:#9ECBFF"> 224.0.0.0/4</span><span style="color:#9ECBFF"> 240.0.0.0/4</span></span>
<span class="line"><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#B392F0">  sudo</span><span style="color:#9ECBFF"> iptables</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> DOCKER-USER</span><span style="color:#79B8FF"> -s</span><span style="color:#79B8FF"> 172.31.50.10</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$cidr</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -j</span><span style="color:#9ECBFF"> REJECT</span><span style="color:#F97583"> 2&gt;</span><span style="color:#9ECBFF">/dev/null</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">    ||</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> iptables</span><span style="color:#79B8FF"> -I</span><span style="color:#9ECBFF"> DOCKER-USER</span><span style="color:#79B8FF"> 2</span><span style="color:#79B8FF"> -s</span><span style="color:#79B8FF"> 172.31.50.10</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$cidr</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -j</span><span style="color:#9ECBFF"> REJECT</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> netfilter-persistent</span><span style="color:#9ECBFF"> save</span></span></code></pre>
<p>These rules block routed private destinations after DNS resolution, so they also help against redirects and DNS rebinding. They do not make linkding safe for hostile tenants: a request to loopback inside the same container never traverses the host’s forwarding chain, and public destinations remain reachable. Keep every account trusted and isolate sensitive services from this Docker network.</p>
<p>After any Docker or firewall change, rerun this test from inside the container. It must reach a public site and fail on both blocked destinations:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> linkding</span><span style="color:#9ECBFF"> python</span><span style="color:#9ECBFF"> -</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;PY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">from urllib.error import HTTPError, URLError</span></span>
<span class="line"><span style="color:#9ECBFF">from urllib.request import ProxyHandler, build_opener</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ECBFF">opener = build_opener(ProxyHandler({}))</span></span>
<span class="line"><span style="color:#9ECBFF">with opener.open(&quot;https://example.com/&quot;, timeout=15) as response:</span></span>
<span class="line"><span style="color:#9ECBFF">    assert 200 &lt;= response.status &lt; 400</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ECBFF">for url in (</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;http://169.254.169.254/latest/meta-data/&quot;,</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;http://192.168.0.1/&quot;,</span></span>
<span class="line"><span style="color:#9ECBFF">):</span></span>
<span class="line"><span style="color:#9ECBFF">    try:</span></span>
<span class="line"><span style="color:#9ECBFF">        opener.open(url, timeout=5)</span></span>
<span class="line"><span style="color:#9ECBFF">    except HTTPError as exc:</span></span>
<span class="line"><span style="color:#9ECBFF">        raise SystemExit(f&quot;blocked destination returned HTTP {exc.code}: {url}&quot;)</span></span>
<span class="line"><span style="color:#9ECBFF">    except URLError:</span></span>
<span class="line"><span style="color:#9ECBFF">        pass</span></span>
<span class="line"><span style="color:#9ECBFF">    else:</span></span>
<span class="line"><span style="color:#9ECBFF">        raise SystemExit(f&quot;blocked destination was reachable: {url}&quot;)</span></span>
<span class="line"><span style="color:#9ECBFF">PY</span></span></code></pre>
<p>The test covers routed destinations only. It says nothing about loopback inside the linkding container, other special-purpose prefixes, an IPv6 path, or a future Docker network with a different source address. Review the rules whenever networking changes.</p>
<h2 id="create-the-superuser-before-public-exposure">Create the superuser before public exposure<a href="#create-the-superuser-before-public-exposure" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The linkding image has no default user. Its <a href="https://linkding.link/installation/#user-setup" title="official user setup" target="_blank" rel="noopener noreferrer">official user setup</a> provides an interactive Django command. Run it while port 9090 is still loopback-only so the password does not enter Compose, <code>.env</code>, or shell history:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> linkding</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  python</span><span style="color:#9ECBFF"> manage.py</span><span style="color:#9ECBFF"> createsuperuser</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --username=admin</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --email=admin@example.com</span></span></code></pre>
<p>Enter a strong unique password at the prompts. Do not set <code>LD_SUPERUSER_PASSWORD</code> for this deployment; it would leave a reusable administrator credential in the container environment.</p>
<p>Open an SSH tunnel from your workstation:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 9090:127.0.0.1:9090</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:9090</code>, sign in, sign out, and sign in again. Confirm that there is no public sign-up flow. Close the tunnel only after the account works.</p>
<h2 id="put-caddy-in-front-of-loopback-port-9090">Put Caddy in front of loopback port 9090<a href="#put-caddy-in-front-of-loopback-port-9090" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Caddy preserves the original <code>Host</code> header, which linkding needs for login and form submissions. Add the public route only after the administrator test:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/linkding.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">bookmarks.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    encode zstd gzip</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:9090</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span></code></pre>
<p>Caddy will obtain a certificate once DNS, UFW, and any provider firewall permit TCP 80/443. Check the redirect and the health endpoint:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSI</span><span style="color:#9ECBFF"> http://bookmarks.example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://bookmarks.example.com/health</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#9ECBFF"> .</span></span></code></pre>
<p>If a save or login request returns <code>403 CSRF verification failed</code>, confirm that <code>.env</code> contains the exact origin with the <code>https://</code> scheme and no path. Do not solve it by trusting every origin or by publishing port 9090.</p>
<h2 id="run-ui-and-api-bookmark-tests">Run UI and API bookmark tests<a href="#run-ui-and-api-bookmark-tests" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Log in at <code>https://bookmarks.example.com</code> and create a bookmark for <code>https://example.com</code>. Set the title to <code>Compose UI test</code>, add the tag <code>compose-test</code>, and put <code>Created through the browser</code> in the description or notes field. Save, reload the page, search for <code>Compose UI test</code>, and confirm the title, tag, and note survive. This browser check tests persistence, not automatic metadata fetching.</p>
<p>Restart the container and check the same bookmark again:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://bookmarks.example.com/health</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#79B8FF"> -e</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;.status == &quot;healthy&quot; and .version == &quot;1.45.0&quot;&#39;</span></span></code></pre>
<p>linkding creates an API token for each user in the Settings page. Read it without echoing, then create a bookmark through the official <a href="https://linkding.link/api/" title="REST API" target="_blank" rel="noopener noreferrer">REST API</a> without supplying a title. A nonempty title in the saved record proves that server-side metadata fetching ran:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">set</span><span style="color:#79B8FF"> -Eeuo</span><span style="color:#9ECBFF"> pipefail</span></span>
<span class="line"><span style="color:#79B8FF">read</span><span style="color:#79B8FF"> -rsp</span><span style="color:#9ECBFF"> &#39;linkding API token: &#39;</span><span style="color:#9ECBFF"> LINKDING_TOKEN</span></span>
<span class="line"><span style="color:#79B8FF">echo</span></span>
<span class="line"><span style="color:#E1E4E8">bookmark_id</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&#39;&#39;</span></span>
<span class="line"><span style="color:#B392F0">cleanup_api_test</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583">  local</span><span style="color:#E1E4E8"> rc</span><span style="color:#F97583">=</span><span style="color:#79B8FF">$?</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> [ </span><span style="color:#F97583">-n</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$bookmark_id</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8"> ]; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#B392F0">    curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">      -H</span><span style="color:#9ECBFF"> &quot;Authorization: Token </span><span style="color:#E1E4E8">$LINKDING_TOKEN</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">      &quot;https://bookmarks.example.com/api/bookmarks/</span><span style="color:#E1E4E8">$bookmark_id</span><span style="color:#9ECBFF">/&quot;</span><span style="color:#F97583"> ||</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#79B8FF">  unset</span><span style="color:#9ECBFF"> LINKDING_TOKEN</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$rc</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"><span style="color:#79B8FF">trap</span><span style="color:#9ECBFF"> cleanup_api_test</span><span style="color:#9ECBFF"> EXIT</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">response</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> POST</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Token </span><span style="color:#E1E4E8">$LINKDING_TOKEN</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &#39;Content-Type: application/json&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -d</span><span style="color:#9ECBFF"> &#39;{&quot;url&quot;:&quot;https://www.iana.org/help/example-domains&quot;,&quot;description&quot;:&quot;Created through REST API&quot;,&quot;tag_names&quot;:[&quot;compose-test&quot;]}&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://bookmarks.example.com/api/bookmarks/</span><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">bookmark_id</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s&#39;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$response</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#79B8FF"> -er</span><span style="color:#9ECBFF"> &#39;.id&#39;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> attempt </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> $(</span><span style="color:#B392F0">seq</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 12</span><span style="color:#E1E4E8">); </span><span style="color:#F97583">do</span></span>
<span class="line"><span style="color:#E1E4E8">  saved</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">    -H</span><span style="color:#9ECBFF"> &quot;Authorization: Token </span><span style="color:#E1E4E8">$LINKDING_TOKEN</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;https://bookmarks.example.com/api/bookmarks/</span><span style="color:#E1E4E8">$bookmark_id</span><span style="color:#9ECBFF">/&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#B392F0"> jq</span><span style="color:#79B8FF"> -e</span><span style="color:#9ECBFF"> &#39;.title | type == &quot;string&quot; and length &gt; 0&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#F97583">    &lt;&lt;&lt;</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$saved</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#F97583">    break</span></span>
<span class="line"><span style="color:#F97583">  fi</span></span>
<span class="line"><span style="color:#E1E4E8">  [ </span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">$attempt</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> -lt</span><span style="color:#79B8FF"> 12</span><span style="color:#E1E4E8"> ] </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#79B8FF">    echo</span><span style="color:#9ECBFF"> &#39;linkding did not fetch a title within 60 seconds&#39;</span><span style="color:#F97583"> &gt;&amp;2</span></span>
<span class="line"><span style="color:#79B8FF">    exit</span><span style="color:#79B8FF"> 1</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#B392F0">  sleep</span><span style="color:#79B8FF"> 5</span></span>
<span class="line"><span style="color:#F97583">done</span></span>
<span class="line"><span style="color:#79B8FF">printf</span><span style="color:#9ECBFF"> &#39;%s&#39;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$saved</span><span style="color:#9ECBFF">&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#9ECBFF"> &#39;{id,url,title,tag_names}&#39;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Token </span><span style="color:#E1E4E8">$LINKDING_TOKEN</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://bookmarks.example.com/api/bookmarks/</span><span style="color:#E1E4E8">$bookmark_id</span><span style="color:#9ECBFF">/&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">bookmark_id</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&#39;&#39;</span></span>
<span class="line"><span style="color:#79B8FF">unset</span><span style="color:#9ECBFF"> LINKDING_TOKEN</span><span style="color:#9ECBFF"> response</span><span style="color:#9ECBFF"> saved</span></span>
<span class="line"><span style="color:#79B8FF">trap</span><span style="color:#9ECBFF"> -</span><span style="color:#9ECBFF"> EXIT</span></span></code></pre>
<p>Delete the browser test bookmark when you are done with it. If the API title stays empty, inspect application logs, DNS, and the outbound rules before weakening any block.</p>
<h2 id="consider-the-optional-chromium-image-carefully">Consider the optional Chromium image carefully<a href="#consider-the-optional-chromium-image-carefully" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>linkding 1.45.0 also publishes <code>sissbruecker/linkding:1.45.0-plus</code>. The <a href="https://linkding.link/archiving/" title="archiving guide" target="_blank" rel="noopener noreferrer">archiving guide</a> says this image adds Chromium, Node.js, SingleFile CLI, and uBlock Origin Lite so the server can create HTML snapshots. Direct PDF URLs are downloaded as assets.</p>
<p>On amd64, the compressed base image was about 136 MB when checked on July 14, 2026, while the plus image was about 556 MB. Upstream recommends at least 1 GB RAM for Chromium, and each retained snapshot consumes data-disk and backup space. Browser processes can also create short CPU and memory spikes, so it is worth watching the real workload for a while rather than sizing from the 1 GB figure alone.</p>
<p>Server-side snapshots expose the VPS egress IP and request timing to the target. They can trigger bot protection, and they do not inherit authenticated sessions from your desktop browser. Snapshots can retain private text, query parameters, or tracking identifiers long after a page changes. Use the browser-based SingleFile method when the exact logged-in page matters, and review the uploaded file before sharing it.</p>
<p>To adopt plus, change only the image line, pull, recreate, and repeat the snapshot and storage checks:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">docker.io/sissbruecker/linkding:1.45.0-plus</span></span></code></pre>
<p>Keep the same egress blocks. Chromium raises the stakes if an untrusted user can submit a URL.</p>
<p>As of July 14, 2026, the <code>1.45.0-plus</code> registry manifest advertises <code>linux/arm/v7</code> while upstream’s archiving documentation still says the plus image is unavailable for ARM v7. The two sources disagree, so on ARM v7, verify that Chromium starts, a snapshot completes, and memory stays within the target host’s limits before relying on plus.</p>
<h2 id="back-up-the-database-assets-secret-and-configuration">Back up the database, assets, secret, and configuration<a href="#back-up-the-database-assets-secret-and-configuration" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>linkding’s <a href="https://linkding.link/backups/" title="backup guide" target="_blank" rel="noopener noreferrer">backup guide</a> provides <code>full_backup</code>. It uses SQLite’s online backup API and writes a ZIP containing the database, assets, favicons, and previews:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#E1E4E8">stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> exec</span><span style="color:#79B8FF"> -T</span><span style="color:#9ECBFF"> linkding</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  python</span><span style="color:#9ECBFF"> manage.py</span><span style="color:#9ECBFF"> full_backup</span><span style="color:#9ECBFF"> /etc/linkding/data/backup.zip</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  data/backup.zip</span><span style="color:#9ECBFF"> &quot;backups/linkding-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.zip&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  data/secretkey.txt</span><span style="color:#9ECBFF"> &quot;backups/secretkey-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.txt&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  compose.yaml</span><span style="color:#9ECBFF"> &quot;backups/compose-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.yaml&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  .env</span><span style="color:#9ECBFF"> &quot;backups/linkding-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.env&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> rm</span><span style="color:#79B8FF"> -f</span><span style="color:#9ECBFF"> data/backup.zip</span></span>
<span class="line"><span style="color:#79B8FF">test</span><span style="color:#79B8FF"> -s</span><span style="color:#9ECBFF"> &quot;backups/linkding-</span><span style="color:#E1E4E8">$stamp</span><span style="color:#9ECBFF">.zip&quot;</span></span></code></pre>
<p>Despite its name, <code>full_backup</code> in 1.45.0 does not include <code>secretkey.txt</code>, Compose, or <code>.env</code>. Preserve all three separately. The ZIP contains password hashes, API tokens, private bookmarks, notes, and possibly archived page content. Encrypt these files and copy them off-host with strict access controls.</p>
<h2 id="restore-on-port-19090-in-isolation">Restore on port 19090 in isolation<a href="#restore-on-port-19090-in-isolation" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Rehearse recovery in a unique directory with the same 1.45.0 image, a timestamped Compose project, port 19090 on loopback, and no Caddy route. An internal Docker network prevents restored background tasks from contacting bookmarked sites or the Wayback Machine. Keep all restore and cleanup commands in one shell.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">set</span><span style="color:#79B8FF"> -Eeuo</span><span style="color:#9ECBFF"> pipefail</span></span>
<span class="line"><span style="color:#E1E4E8">restore_stamp</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%Y%m%d%H%M%S)-</span><span style="color:#79B8FF">$$</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">restore_project</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;linkding_restore_</span><span style="color:#E1E4E8">$restore_stamp</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">restore_dir</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">&quot;/opt/linkding-restore-</span><span style="color:#E1E4E8">$restore_stamp</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">backup_zip</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">find</span><span style="color:#9ECBFF"> /opt/linkding/backups</span><span style="color:#79B8FF"> -maxdepth</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> -type</span><span style="color:#9ECBFF"> f</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -name</span><span style="color:#9ECBFF"> &#39;linkding-*.zip&#39;</span><span style="color:#79B8FF"> -printf</span><span style="color:#9ECBFF"> &#39;%p\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sort</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> tail</span><span style="color:#79B8FF"> -n</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#79B8FF">test</span><span style="color:#79B8FF"> -s</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$backup_zip</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">backup_stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">$(</span><span style="color:#B392F0">basename</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$backup_zip</span><span style="color:#9ECBFF">&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">backup_stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">${backup_stamp</span><span style="color:#F97583">#</span><span style="color:#E1E4E8">linkding-}</span></span>
<span class="line"><span style="color:#E1E4E8">backup_stamp</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">${backup_stamp</span><span style="color:#F97583">%</span><span style="color:#E1E4E8">.zip}</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -g</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$USER</span><span style="color:#9ECBFF">&quot;</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">/data&quot;</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_dir</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">unzip</span><span style="color:#79B8FF"> -q</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$backup_zip</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> -d</span><span style="color:#9ECBFF"> data</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;/opt/linkding/backups/secretkey-</span><span style="color:#E1E4E8">$backup_stamp</span><span style="color:#9ECBFF">.txt&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  data/secretkey.txt</span></span>
<span class="line"><span style="color:#B392F0">install</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 600</span><span style="color:#9ECBFF"> &quot;/opt/linkding/backups/linkding-</span><span style="color:#E1E4E8">$backup_stamp</span><span style="color:#9ECBFF">.env&quot;</span><span style="color:#9ECBFF"> .env</span></span></code></pre>
<p>While still in the unique <code>$restore_dir</code> created above, create <code>compose.restore.yaml</code> in the current directory:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  linkding</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">docker.io/sissbruecker/linkding:1.45.0</span></span>
<span class="line"><span style="color:#85E89D">    ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">&quot;127.0.0.1:19090:9090&quot;</span></span>
<span class="line"><span style="color:#85E89D">    volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">./data:/etc/linkding/data</span></span>
<span class="line"><span style="color:#85E89D">    env_file</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">.env</span></span>
<span class="line"><span style="color:#85E89D">    networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#9ECBFF">restore_only</span></span>
<span class="line"><span style="color:#85E89D">    restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;no&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">networks</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  restore_only</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    internal</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span></code></pre>
<p>Start the recovery project and check its health endpoint:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -f</span><span style="color:#9ECBFF"> compose.restore.yaml</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:19090/health</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#9ECBFF"> .</span></span></code></pre>
<p>Tunnel workstation port 19090 to the VPS, log in with the restored account, and compare bookmark, tag, and note counts. Fetch several saved bookmarks without asking linkding to refresh them. Confirm the existing API token can read the restored API. If you use snapshots, open a sample from local restored assets while the source site remains unreachable from the internal network.</p>
<p>Remove the test project after recording a successful result:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$restore_project</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -f</span><span style="color:#9ECBFF"> compose.restore.yaml</span><span style="color:#9ECBFF"> down</span></span></code></pre>
<p>Never run <code>down -v</code> in the production directory. This bind-mount deployment does not need it, and the habit is dangerous around other stateful Compose projects.</p>
<h2 id="upgrade-with-a-controlled-recovery-point">Upgrade with a controlled recovery point<a href="#upgrade-with-a-controlled-recovery-point" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Read the <a href="https://github.com/sissbruecker/linkding/releases" title="linkding releases" target="_blank" rel="noopener noreferrer">linkding releases</a>, run <code>full_backup</code>, save <code>secretkey.txt</code>, Compose, and <code>.env</code>, and verify the backup with the isolated same-version drill. Note the current image tag, change it to the reviewed release, then pull and recreate:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span><span style="color:#9ECBFF"> linkding</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> --wait</span><span style="color:#79B8FF"> --wait-timeout</span><span style="color:#79B8FF"> 180</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://bookmarks.example.com/health</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> jq</span><span style="color:#9ECBFF"> .</span></span></code></pre>
<p>linkding runs Django migrations during container startup. Repeat administrator login, UI create/search/delete, API create/read/delete, and snapshot tests if applicable. If the migration fails, preserve the upgraded data directory for diagnosis and restore the old image together with the pre-upgrade ZIP and matching <code>secretkey.txt</code>. An image-only downgrade may not reverse a database migration.</p>
<h2 id="choosing-a-riven-cloud-vps-for-linkding">Choosing a Riven Cloud VPS for linkding<a href="#choosing-a-riven-cloud-vps-for-linkding" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a private bookmark service, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a comfortable starting point, with plenty of headroom over the 1 GB RAM that upstream suggests for the Chromium-based plus image. If you archive heavily, snapshots and previews are what grow, so keep an eye on the data directory and step up to Ultra (8 GB RAM, 80 GB NVMe, 2 TB transfer) when disk becomes the constraint.</p>
<p>Every plan includes a 1 Gbps port, full root access, daily backups, and deployment in Tokyo or Singapore. Our plans are unmanaged, so you run the stack yourself and keep full control. Pick the location near the people opening the bookmark UI; if you browse from mainland China, a quick comparison of the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own carrier will show which route serves you better. Current plan details are on the <a href="https://sa.net/pricing/" title="pricing page">pricing page</a>.</p>
<p>One note on the daily provider backups: they make a good extra layer, but keep running <code>full_backup</code> with the separate secret and configuration copies, and keep rehearsing the port-19090 restore. An application-level backup you have tested is the one you can count on.</p>
<h2 id="privacy-surfaces-security-limits-and-non-fit-cases">Privacy surfaces, security limits, and non-fit cases<a href="#privacy-surfaces-security-limits-and-non-fit-cases" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Bookmark metadata fetching tells target sites the VPS egress IP. If a user enables favicons, linkding’s default <code>LD_FAVICON_PROVIDER</code> is a Google endpoint and sends the bookmarked scheme and hostname to Google. Configure a provider you accept or leave favicons disabled. Internet Archive integration can submit bookmark URLs to the Wayback Machine, which is inappropriate for private or token-bearing URLs. Page snapshots may retain sensitive content in backups.</p>
<p>Sharing and public sharing default to off, but a user who enables them can create views or feeds available without login. Check those profile settings and test logged-out access. Never assume a bookmark is private merely because the main application has a password.</p>
<p>Leave <code>LD_ENABLE_AUTH_PROXY=False</code> unless an authentication proxy is deliberately designed and tested. When enabled, linkding trusts the configured identity header and can automatically create users. The edge proxy must remove any client-supplied copy of that header, insert only an authenticated identity, and remain the sole path to port 9090. A forged <code>Remote-User</code> style header can otherwise become an account takeover.</p>
<p>The URL validator and egress rules are layered controls, not proof that linkding is safe for arbitrary tenants. Keep registration closed, protect API tokens like passwords, monitor outbound traffic and disk growth, and isolate sensitive host or private-network services.</p>
<p>If nobody on your side wants to own TLS, firewall persistence, patches, encrypted backups, and restore tests, a managed bookmark service or a browser-local solution will serve you better. linkding is also a poor fit for untrusted multi-user hosting, for archives containing regulated or confidential pages, or anywhere server-side fetching cannot be permitted. If public sharing, hostile users, or reliable authenticated-page archiving are core requirements, pick a system designed for those boundaries rather than stretching this deployment to fit them.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host RustDesk with Docker for China Remote Support]]></title>
        <id>https://sa.net/blog/self-host-rustdesk-china-remote-support/</id>
        <link href="https://sa.net/blog/self-host-rustdesk-china-remote-support/"/>
        <updated>2026-07-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A Docker tutorial for running RustDesk Server OSS as a Sunlogin cross-border alternative on a Tokyo VPS.]]></summary>
        <content type="html"><![CDATA[<p>On July 13, 2026, the Sunlogin client quoted us <strong>¥188 per month</strong> for cross-border remote control from Japan into mainland China. That ¥188 figure was an in-client, point-in-time quote. Sunlogin’s <a href="https://sunlogin.oray.com/price" title="public pricing page" target="_blank" rel="noopener noreferrer">public pricing page</a> did not list it when we checked, so it should not be read as a universal public price.</p>
<p>We needed occasional support sessions, not a subscription. We therefore self-hosted RustDesk Server OSS on an existing Riven Cloud Tokyo VPS while keeping the familiar remote-support workflow for the person receiving help.</p>
<h2 id="why-self-host-rustdesk">Why self-host RustDesk?<a href="#why-self-host-rustdesk" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Self-hosting gives the operator control of the server identity and relay path. RustDesk generates an Ed25519 key pair when <code>hbbs</code> first starts. The public key identifies and authenticates the self-hosted server to clients configured with that key. It is not a password, secret, or access-control list. Give clients only the public key and never expose the private <code>id_ed25519</code> file.</p>
<p>RustDesk sessions are end-to-end encrypted. A relay carries encrypted session traffic rather than an unencrypted desktop stream. With self-hosting, even that encrypted relay traffic stays on infrastructure we control instead of passing through a third-party relay.</p>
<p>RustDesk Server OSS also has no membership tiers, device-count limits, or per-feature paywalls. It provides the core ID and relay services without the web console, account management, and other administrative features offered by RustDesk Server Pro.</p>
<p>Relay placement is another practical benefit. We can choose the location and carrier path instead of accepting whichever relay the vendor assigns. This matters when a direct peer-to-peer connection cannot be established and the desktop session must cross the relay in both directions.</p>
<p>SSH, WireGuard, and Tailscale are excellent tools for machines and users we administer. They were wrong for this case because the remote party was a non-technical third party. The workflow needed to remain: install the client, share the ID, and approve the session. Requiring a tunnel would have created a second support job before we could address the first one.</p>
<h2 id="how-do-you-install-rustdesk-server-oss-with-docker">How do you install RustDesk Server OSS with Docker?<a href="#how-do-you-install-rustdesk-server-oss-with-docker" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The <a href="https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/install/" title="official RustDesk Server OSS installation guide" target="_blank" rel="noopener noreferrer">official RustDesk Server OSS installation guide</a> recommends Docker for most deployments. The method below follows that guide and the current <a href="https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/docker/" title="Docker documentation" target="_blank" rel="noopener noreferrer">Docker documentation</a> for a portable Compose deployment.</p>
<h3 id="check-the-prerequisites">Check the prerequisites<a href="#check-the-prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Use a Linux VPS with a public IPv4 address. Dual-stack IPv4 and IPv6 is preferred when both client networks support it. You also need <code>sudo</code> or root access and enough firewall control to expose the RustDesk ports.</p>
<p>A DNS hostname is optional but recommended. The examples use <code>rustdesk.example.com</code>; replace it with your own hostname. A public IP address also works, but a hostname is easier to move to another server later.</p>
<h3 id="install-docker-and-start-the-services">Install Docker and start the services<a href="#install-docker-and-start-the-services" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Create a working directory so that the Compose file and persistent server data stay together:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">mkdir</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> ~/rustdesk-server</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> ~/rustdesk-server</span></span></code></pre>
<p>Install Docker with the convenience installer used by the official documentation. Operators who already have a working Docker Engine and Compose plugin can skip this command:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">bash</span><span style="color:#9ECBFF"> &lt;(</span><span style="color:#B392F0">wget</span><span style="color:#79B8FF"> -qO-</span><span style="color:#9ECBFF"> https://get.docker.com)</span></span></code></pre>
<p>Download the official Compose file, then start both services:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">wget</span><span style="color:#9ECBFF"> https://rustdesk.com/oss.yml</span><span style="color:#79B8FF"> -O</span><span style="color:#9ECBFF"> compose.yml</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span></code></pre>
<p>At the time of writing, the live <code>oss.yml</code> starts <code>hbbs</code> and <code>hbbr</code> from <code>rustdesk/rustdesk-server:latest</code>. Both containers mount <code>./data:/root</code>, use host networking, and restart with the <code>unless-stopped</code> policy.</p>

















<table><thead><tr><th>Service</th><th>Role</th></tr></thead><tbody><tr><td><code>hbbs</code></td><td>ID registration, rendezvous, signaling, and NAT traversal</td></tr><tr><td><code>hbbr</code></td><td>Encrypted session relay when direct hole punching fails</td></tr></tbody></table>
<p>Host networking means the processes listen directly on the VPS network stack. Check that both containers are running and inspect the <code>hbbs</code> startup output:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> ps</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=100</span><span style="color:#9ECBFF"> hbbs</span></span></code></pre>
<h3 id="open-the-firewall">Open the firewall<a href="#open-the-firewall" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>The official guide gives this simple UFW example:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 21114:21119/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 21116/udp</span></span></code></pre>
<p>The range is broader than the minimum RustDesk Server OSS set. Port <code>21114/tcp</code> serves the Pro web console and is not required by OSS. Port <code>21115/tcp</code> is used for the NAT type test. Port <code>21116/tcp</code> handles connections and TCP hole punching, while <code>21116/udp</code> handles ID registration and heartbeat traffic. Port <code>21117/tcp</code> carries relay sessions. Ports <code>21118/tcp</code> and <code>21119/tcp</code> support web clients and are optional when web-client access is unused.</p>
<p>Port <code>21116</code> must be allowed over both TCP and UDP. A tighter OSS policy can omit <code>21114/tcp</code> and, when appropriate, the two web-client ports. Mirror the chosen rules in any cloud firewall or security group outside the VPS. If the service is reachable over IPv6, verify that UFW created matching IPv6 rules rather than checking only the IPv4 table.</p>
<h3 id="retrieve-the-public-key-safely">Retrieve the public key safely<a href="#retrieve-the-public-key-safely" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>The downloaded Compose file mounts the host’s <code>./data</code> directory at <code>/root</code> inside both containers. After the first start, the host-side public key is therefore normally <code>./data/id_ed25519.pub</code>.</p>
<p>File layout can vary with the server version or deployment method. Find public-key files in the persistent directory if the expected path is absent:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> find</span><span style="color:#9ECBFF"> ./data</span><span style="color:#79B8FF"> -maxdepth</span><span style="color:#79B8FF"> 2</span><span style="color:#79B8FF"> -name</span><span style="color:#9ECBFF"> &#39;*.pub&#39;</span><span style="color:#79B8FF"> -type</span><span style="color:#9ECBFF"> f</span><span style="color:#79B8FF"> -print</span></span></code></pre>
<p>Read the expected public key with:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> cat</span><span style="color:#9ECBFF"> ./data/id_ed25519.pub</span></span></code></pre>
<p>The <code>hbbs</code> log can also show the generated key and help diagnose a different data layout:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#9ECBFF"> hbbs</span></span></code></pre>
<p>Copy only the content of <code>id_ed25519.pub</code> into client configuration. Never share, publish, or paste <code>id_ed25519</code>, which is the server’s private key. Back up the persistent data directory securely because replacing the private key changes the server identity expected by configured clients.</p>
<h3 id="configure-the-clients">Configure the clients<a href="#configure-the-clients" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>On each RustDesk client, open <strong>Settings &gt; Network</strong> and set:</p>
<ul>
<li><strong>ID Server:</strong> <code>rustdesk.example.com</code> or your public IP address</li>
<li><strong>Key:</strong> the content of <code>id_ed25519.pub</code></li>
<li><strong>Relay Server:</strong> leave blank</li>
<li><strong>API Server:</strong> leave blank</li>
</ul>
<p>Both ends of the session need the self-hosted ID server and public key. <code>hbbs</code> advertises the associated <code>hbbr</code> relay automatically, so a separate relay value is unnecessary. The API field is for Pro features and remains blank for this OSS setup.</p>
<h3 id="verify-a-real-client-to-client-session">Verify a real client-to-client session<a href="#verify-a-real-client-to-client-session" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Test between two clients on different networks rather than stopping after the containers report healthy. Confirm that both clients receive IDs through the new server, connect, and can exchange screen updates, keyboard and mouse input, and clipboard content.</p>
<p>In our actual test, Linux and Windows clients completed a real relayed session. Screen, keyboard and mouse input, and clipboard all worked. Direct hole punching failed for that test, so <code>hbbr</code> relayed the session. One successful session is an operational check, not a controlled network benchmark, and we are not using it to claim a latency figure.</p>
<p>The official installation guide says CPU and memory requirements are minimal. It estimates roughly <strong>30 KB/s to 3 MB/s</strong> for a relayed 1920×1080 session, depending on resolution and screen activity, with office work around <strong>100 KB/s</strong>. These are broad upstream estimates, not capacity or performance guarantees. Size transfer allowance and monitoring for your own concurrency and usage pattern.</p>
<h2 id="why-use-a-tokyo-vps-for-support-into-mainland-china">Why use a Tokyo VPS for support into mainland China?<a href="#why-use-a-tokyo-vps-for-support-into-mainland-china" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A RustDesk relay is more of a network workload than a compute workload. Once the small CPU and memory requirement is met, adding vCPU does not repair a poor return path. Relay placement matters because a failed direct connection sends the interactive session through <code>hbbr</code>.</p>
<p>This is Riven Cloud’s own blog, and we sell the VPS used for this deployment. Our Tokyo network has carrier-specific paths for the three major mainland China access networks:</p>
<ul>
<li>China Telecom: <strong>CTGNet, formerly China Telecom CN2 GIA, AS23764 / AS4809</strong></li>
<li>China Unicom: <strong>CUP, China Unicom Premium, AS9929 / AS10099</strong></li>
<li>China Mobile: <strong>CMIN2, China Mobile International N2, AS58807</strong></li>
</ul>
<p>Our <a href="https://sa.net/blog/what-are-china-optimized-routes/" title="China-optimized routes explainer">China-optimized routes explainer</a> describes what those names mean and how to inspect the path. Operators should test from the networks that will actually use the service. The <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> provides a starting point for ping and traceroute checks, but testing from the client access network remains important.</p>
<p>Performance depends on the access carrier, province, last mile, time of day, and whether RustDesk establishes a direct connection. A direct connection may bypass the relay workload entirely. A relayed session takes the server path, but no single Tokyo test proves how every mainland China access line will behave.</p>
<h2 id="when-is-self-hosting-not-worth-it">When is self-hosting not worth it?<a href="#when-is-self-hosting-not-worth-it" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>One-off users and operators unwilling to maintain updates, firewall policy, keys, and monitoring should use a hosted remote-support vendor. That fee buys less operational responsibility.</p>
<p>Self-hosting fits better when a suitable VPS already exists, support sessions recur, infrastructure control matters, or the workload needs a specific location and carrier path. The calculation should include server cost, transfer, and operator time rather than comparing only two monthly prices.</p>
<p>Test the <a href="https://tokyo.lg.sa.net/" title="Tokyo route" target="_blank" rel="noopener noreferrer">Tokyo route</a> from the networks that matter, then compare <a href="https://sa.net/pricing/" title="Riven Cloud VPS pricing">Riven Cloud VPS pricing</a> with the subscription cost and maintenance burden being replaced.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="RustDesk" term="RustDesk"/>
        <category label="China Connectivity" term="China Connectivity"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Self-Host Vaultwarden with Docker]]></title>
        <id>https://sa.net/blog/self-host-vaultwarden/</id>
        <link href="https://sa.net/blog/self-host-vaultwarden/"/>
        <updated>2026-07-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Deploy Vaultwarden 1.36.0 with Docker Compose and Caddy TLS, keep registration private, and prove your vault backup actually restores.]]></summary>
        <content type="html"><![CDATA[<p>Vaultwarden is an AGPL-3.0-licensed, community-developed implementation of the Bitwarden Client API. It works with the official Bitwarden clients, but it is not Bitwarden Inc.’s official server and is not associated with Bitwarden Inc. That distinction matters: you operate the server, track compatibility, and handle recovery.</p>
<p>This guide installs Vaultwarden 1.36.0, the current release when this article was checked on July 14, 2026, on a fresh Debian or Ubuntu VPS. Docker publishes the application only on <code>127.0.0.1:8080</code>; Caddy handles public HTTPS at <code>vault.example.com</code>. The result supports the web vault and official desktop, browser, and mobile clients without exposing the container’s HTTP port.</p>
<p>If you are comparing several services before choosing one, start with our <a href="https://sa.net/blog/best-self-hosted-apps/" title="self-hosted apps for a VPS">self-hosted apps for a VPS</a> guide. A password vault deserves more care than a weekend test server.</p>
<h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>You need:</p>
<ul>
<li>a Debian or Ubuntu VPS with root or sudo access;</li>
<li>an A record for <code>vault.example.com</code> pointing to the VPS IPv4 address, plus an AAAA record only if IPv6 works end to end;</li>
<li>TCP 80 and 443 reachable for Caddy and certificate issuance;</li>
<li>a second device with an official Bitwarden client for the sync test;</li>
<li>encrypted backup storage outside the VPS.</li>
</ul>
<p>Secure SSH before putting another login page on the Internet. Our <a href="https://sa.net/blog/secure-ssh-on-a-vps/" title="SSH hardening guide">SSH hardening guide</a> uses a test-first order that avoids the usual lockout.</p>
<p>Install Docker Engine and the Compose plugin using Docker’s convenience script. If running a downloaded script as root makes you uneasy, read it before executing it, or install from Docker’s apt repository instead:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsSL</span><span style="color:#9ECBFF"> https://get.docker.com</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> bash</span><span style="color:#9ECBFF"> get-docker.sh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> version</span></span></code></pre>
<p>Check DNS before asking Caddy for a certificate. The answer must be this VPS; omit the AAAA record if the server is not ready to serve IPv6:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">getent</span><span style="color:#9ECBFF"> ahostsv4</span><span style="color:#9ECBFF"> vault.example.com</span></span></code></pre>
<p>Install Caddy from the distribution repository on a fresh Debian or Ubuntu host. Caddy’s <a href="https://caddyserver.com/docs/install" title="official installation page" target="_blank" rel="noopener noreferrer">official installation page</a> documents its upstream packages if your distribution does not provide a suitable build.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -y</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> ufw</span></span></code></pre>
<p>Allow the administrative SSH port before enabling UFW. This example assumes SSH still uses port 22:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> enable</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> verbose</span></span></code></pre>
<p>If SSH uses another port, replace <code>22</code> before enabling the firewall. Mirror the same inbound policy in the VPS provider’s cloud firewall or security group. Only SSH and TCP 80/443 should be public. Port 8080 stays on loopback.</p>
<h2 id="create-the-compose-deployment">Create the Compose deployment<a href="#create-the-compose-deployment" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep the Compose file and persistent data under <code>/opt/vaultwarden</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /opt/vaultwarden/vw-data</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span></code></pre>
<p>Create <code>/opt/vaultwarden/compose.yml</code>. The file follows Vaultwarden’s official container and proxy guidance; the exact tag, loopback binding, and log rotation are our choices.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> compose.yml</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;YAML&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">services:</span></span>
<span class="line"><span style="color:#9ECBFF">  vaultwarden:</span></span>
<span class="line"><span style="color:#9ECBFF">    image: vaultwarden/server:1.36.0</span></span>
<span class="line"><span style="color:#9ECBFF">    container_name: vaultwarden</span></span>
<span class="line"><span style="color:#9ECBFF">    restart: unless-stopped</span></span>
<span class="line"><span style="color:#9ECBFF">    environment:</span></span>
<span class="line"><span style="color:#9ECBFF">      DOMAIN: &quot;https://vault.example.com&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">      SIGNUPS_ALLOWED: &quot;true&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    volumes:</span></span>
<span class="line"><span style="color:#9ECBFF">      - ./vw-data:/data</span></span>
<span class="line"><span style="color:#9ECBFF">    ports:</span></span>
<span class="line"><span style="color:#9ECBFF">      - &quot;127.0.0.1:8080:80&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">    logging:</span></span>
<span class="line"><span style="color:#9ECBFF">      options:</span></span>
<span class="line"><span style="color:#9ECBFF">        max-size: &quot;10m&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">        max-file: &quot;3&quot;</span></span>
<span class="line"><span style="color:#9ECBFF">YAML</span></span></code></pre>
<p>The exact image tag makes this deployment reproducible. It also means updates are deliberate. Read the <a href="https://github.com/dani-garcia/vaultwarden/releases" title="Vaultwarden release notes" target="_blank" rel="noopener noreferrer">Vaultwarden release notes</a>, take a backup, and change the tag yourself when a suitable release is available.</p>
<p>The <code>vaultwarden</code> service listens on port 80 inside the container and appears only at <code>127.0.0.1:8080</code> on the VPS. WebSocket traffic has shared the main HTTP port since Vaultwarden 1.31.0, which removed the separate WebSocket port, so 1.36.0 needs only this one port. Do not expose the obsolete WebSocket port 3012 found in old tutorials.</p>
<p>The bind mount maps <code>./vw-data</code> to <code>/data</code>. It holds the SQLite database, attachments, file Sends, configuration written by Vaultwarden, RSA signing keys, and the icon cache. Losing that directory is losing the service state.</p>
<p>Start the service:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> vaultwarden</span></span></code></pre>
<p>Confirm the host binding. The output should show <code>127.0.0.1:8080</code>, not <code>0.0.0.0:8080</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ss</span><span style="color:#79B8FF"> -lntp</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#9ECBFF"> &#39;:8080&#39;</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:8080/alive</span></span></code></pre>
<h2 id="create-the-first-account-through-an-ssh-tunnel">Create the first account through an SSH tunnel<a href="#create-the-first-account-through-an-ssh-tunnel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Do not publish the Caddy route yet. <code>SIGNUPS_ALLOWED=true</code> keeps registration globally open until you change it; Vaultwarden does not close registration automatically after the first account. The loopback-only Docker binding is the control that keeps this bootstrap page off the Internet.</p>
<p>From your workstation, open an SSH tunnel to the VPS and leave that command running:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -N</span><span style="color:#79B8FF"> -L</span><span style="color:#9ECBFF"> 8080:127.0.0.1:8080</span><span style="color:#9ECBFF"> YOUR_USER@SERVER_IP</span></span></code></pre>
<p>Browse to <code>http://127.0.0.1:8080</code>, choose <strong>Create account</strong>, and create the intended account with a unique master password. Store the recovery code for two-step login somewhere separate from this server. Only someone who can authenticate to SSH and establish this tunnel can reach the loopback listener.</p>
<p>Close registration before making the service public:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sed</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> &#39;s/SIGNUPS_ALLOWED: &quot;true&quot;/SIGNUPS_ALLOWED: &quot;false&quot;/&#39;</span><span style="color:#9ECBFF"> compose.yml</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:8080/alive</span></span></code></pre>
<p>While still using the tunnel, sign out, reload the web vault, and attempt to create a second disposable account. Vaultwarden must deny the registration; if it does not, stop and check <code>compose.yml</code>. Once denial is confirmed, enable two-step login on the intended account and close the tunnel.</p>
<h2 id="put-caddy-in-front">Put Caddy in front<a href="#put-caddy-in-front" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The Bitwarden web vault uses Web Crypto APIs that browsers expose only in a secure context. Public use therefore requires HTTPS. Vaultwarden’s <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS" title="HTTPS guidance" target="_blank" rel="noopener noreferrer">HTTPS guidance</a> recommends a reverse proxy rather than the application’s built-in TLS support.</p>
<p>Now that public registration is closed, add an imported Caddy site snippet without replacing any existing hosts:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/caddy/sites-enabled/vaultwarden.caddy</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;CADDY&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">vault.example.com {</span></span>
<span class="line"><span style="color:#9ECBFF">    reverse_proxy 127.0.0.1:8080 {</span></span>
<span class="line"><span style="color:#9ECBFF">        header_up X-Real-IP {remote_host}</span></span>
<span class="line"><span style="color:#9ECBFF">    }</span></span>
<span class="line"><span style="color:#9ECBFF">}</span></span>
<span class="line"><span style="color:#9ECBFF">CADDY</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#B392F0"> sudo</span><span style="color:#9ECBFF"> grep</span><span style="color:#79B8FF"> -Eq</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &#39;^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*&#39;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  /etc/caddy/Caddyfile</span><span style="color:#E1E4E8">; </span><span style="color:#F97583">then</span></span>
<span class="line"><span style="color:#79B8FF">  printf</span><span style="color:#9ECBFF"> &#39;\nimport /etc/caddy/sites-enabled/*\n&#39;</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#B392F0">    sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#79B8FF"> -a</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span></span>
<span class="line"><span style="color:#F97583">fi</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> caddy</span><span style="color:#9ECBFF"> validate</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> /etc/caddy/Caddyfile</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> reload</span><span style="color:#9ECBFF"> caddy</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://vault.example.com/alive</span></span></code></pre>
<p>Caddy obtains and renews a public certificate when DNS is correct and ports 80 and 443 reach this server. If issuance fails, check the A/AAAA records, UFW, the provider firewall, and <code>sudo journalctl -u caddy -n 100 --no-pager</code>.</p>
<p>Vaultwarden’s hardening guide notes that WebSocket notification URLs can contain an access token in the query string. Protect proxy logs and avoid configurations that publish full request URLs to a third-party log service.</p>
<p>This base deployment deliberately has no <code>/admin</code> panel. The panel is optional and has a separate authentication boundary. If you later need it, follow the <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page" title="official admin guide" target="_blank" rel="noopener noreferrer">official admin guide</a>, generate an Argon2id PHC token with the image’s <code>hash</code> command, keep it in a mode-600 <code>.env</code>, and restrict <code>/admin</code> at the proxy. Never put a plaintext admin token in Compose or shell history.</p>
<p>Vaultwarden administrators cannot recover a forgotten master password or decrypt vault contents. Account backups and recovery planning still belong to each user.</p>
<h2 id="verify-a-real-bitwarden-client-sync">Verify a real Bitwarden client sync<a href="#verify-a-real-bitwarden-client-sync" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A running container does not prove the client workflow works. Confirm the public endpoint first:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://vault.example.com/alive</span></span></code></pre>
<p>Install an official Bitwarden client on a second device. Before logging in, choose the self-hosted or custom server option and set the server URL to <code>https://vault.example.com</code>. The label varies slightly by client, but the URL must be set before authentication.</p>
<p>Add a disposable login item in the web vault and trigger sync. Log in from the second client, sync, and confirm that the item appears. Edit its notes in the client, sync again, and verify the change in the web vault. Delete the test item when finished. If you depend on attachments, repeat the test with a harmless attachment.</p>
<h2 id="back-up-and-test-a-restore">Back up and test a restore<a href="#back-up-and-test-a-restore" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Vaultwarden’s <a href="https://github.com/dani-garcia/vaultwarden/wiki/Backing-up-your-vault" title="backup documentation" target="_blank" rel="noopener noreferrer">backup documentation</a> recommends the SQLite online backup API. The image exposes a built-in command:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> exec</span><span style="color:#9ECBFF"> vaultwarden</span><span style="color:#9ECBFF"> /vaultwarden</span><span style="color:#9ECBFF"> backup</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> find</span><span style="color:#9ECBFF"> vw-data</span><span style="color:#79B8FF"> -maxdepth</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> -type</span><span style="color:#9ECBFF"> f</span><span style="color:#79B8FF"> -name</span><span style="color:#9ECBFF"> &#39;db.sqlite3*&#39;</span><span style="color:#79B8FF"> -ls</span></span></code></pre>
<p>That protects the database, but a complete backup must also preserve <code>attachments/</code>, <code>sends/</code> if used, <code>config.json</code>, and <code>rsa_key*</code>. The icon cache is optional. With everything in a single bind mount, a stopped-container archive covers all of it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 700</span><span style="color:#9ECBFF"> /var/backups/vaultwarden</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/vaultwarden</span><span style="color:#79B8FF"> -czf</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;/var/backups/vaultwarden/vaultwarden-$(</span><span style="color:#B392F0">date</span><span style="color:#9ECBFF"> +%F-%H%M%S).tar.gz&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  vw-data</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> start</span><span style="color:#9ECBFF"> vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://vault.example.com/alive</span></span></code></pre>
<p>Encrypt the archive and copy it off the VPS. Daily backups come with every Riven Cloud plan and are a welcome safety net, but they are no substitute for an application-consistent backup and a rehearsed restore.</p>
<p>Test restoration on an isolated VPS, never over the only working copy. Deploy the same image version and transfer one archive to the test host. Set <code>BACKUP</code> to its real path, then restore it while keeping the empty directory as a rollback point:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#E1E4E8"> BACKUP</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">/path/to/vaultwarden-YYYY-MM-DD-HHMMSS.tar.gz</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> stop</span><span style="color:#9ECBFF"> vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mv</span><span style="color:#9ECBFF"> vw-data</span><span style="color:#9ECBFF"> vw-data.empty</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tar</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> /opt/vaultwarden</span><span style="color:#79B8FF"> -xzf</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#E1E4E8">$BACKUP</span><span style="color:#9ECBFF">&quot;</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> http://127.0.0.1:8080/alive</span></span></code></pre>
<p>If restoring an online SQLite <code>.backup</code> rather than the stopped archive, remove any stale unmatched <code>db.sqlite3-wal</code> first. Then log in through HTTPS and repeat the two-client sync and attachment test. Until an archive has been restored at least once, you do not have a recovery plan.</p>
<h2 id="upgrade-without-changing-versions-by-accident">Upgrade without changing versions by accident<a href="#upgrade-without-changing-versions-by-accident" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Review the release notes for compatibility and migration warnings. Take and test a backup, edit only the image tag in <code>compose.yml</code>, then run:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> /opt/vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> pull</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> up</span><span style="color:#79B8FF"> -d</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> docker</span><span style="color:#9ECBFF"> compose</span><span style="color:#9ECBFF"> logs</span><span style="color:#79B8FF"> --tail=50</span><span style="color:#9ECBFF"> vaultwarden</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -fsS</span><span style="color:#9ECBFF"> https://vault.example.com/alive</span></span></code></pre>
<p>Repeat the official-client sync test. Do not switch to <code>latest</code> to avoid future maintenance; Bitwarden clients update frequently, so Vaultwarden compatibility still needs deliberate attention.</p>
<h2 id="choosing-a-riven-cloud-vps-for-vaultwarden">Choosing a Riven Cloud VPS for Vaultwarden<a href="#choosing-a-riven-cloud-vps-for-vaultwarden" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a private vault, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is plenty; Vaultwarden itself is light. If the same VPS will also carry monitoring, SMTP, backup tooling, or other containers, our Ultra plan (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB) buys comfortable headroom. Either way, keep the vault isolated from untrusted workloads even when spare RAM makes co-hosting look convenient.</p>
<p>Every plan is an unmanaged KVM VPS with full root access, a 1 Gbps port, and automatic daily backups, so you run the stack yourself and keep full control. Choose Tokyo or Singapore based on where your clients connect from. If that is mainland China, a quick check of the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and <a href="https://singapore.lg.sa.net/" title="Singapore Looking Glass" target="_blank" rel="noopener noreferrer">Singapore Looking Glass</a> from your own carrier, at the hours you actually use the vault, will show which location fits better. Then compare the <a href="https://sa.net/pricing/" title="Riven Cloud VPS plans">Riven Cloud VPS plans</a>.</p>
<h2 id="security-limits">Security limits<a href="#security-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep registration closed, require strong master passwords and two-step login, encrypt off-server backups, and patch both the host and container. Do not mount the Docker socket or unrelated host directories into Vaultwarden. Configure SMTP if you rely on invitations, verification, or mail-based workflows, and protect those credentials with the same care as the vault data.</p>
<p>Rate limiting can reduce noisy login attempts, but it depends on correct forwarded-client-IP handling. It cannot compensate for weak passwords or missing 2FA.</p>
<h2 id="when-not-to-self-host">When not to self-host<a href="#when-not-to-self-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Use Bitwarden’s hosted service or official self-hosted offering if you need vendor support, formal compliance features, managed availability, or someone else to own incident response. Vaultwarden is also a poor fit when you cannot maintain DNS and HTTPS, install security updates promptly, keep encrypted off-site backups, and rehearse restores. Password storage is the wrong place to learn whether your backup process works.</p>
<p>If that responsibility is more than you want to take on, there is no shame in a managed password service; a vault someone maintains well beats a vault nobody maintains.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Self-hosting" term="Self-hosting"/>
        <category label="Docker" term="Docker"/>
        <category label="VPS" term="VPS"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Nginx vs Apache vs Caddy: How to Choose a Web Server in 2026]]></title>
        <id>https://sa.net/blog/nginx-vs-apache-vs-caddy/</id>
        <link href="https://sa.net/blog/nginx-vs-apache-vs-caddy/"/>
        <updated>2026-07-02T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[In 2026, TLS certificates are headed for 47-day lifetimes; choosing Nginx, Apache, or Caddy means weighing ACME, HTTP/3, and control.]]></summary>
        <content type="html"><![CDATA[<p>The old Nginx vs Apache vs Caddy debate usually starts with benchmark charts. In 2026, that is the wrong place to start.</p>
<p>The CA/Browser Forum approved <a href="https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/" title="Ballot SC-081v3" target="_blank" rel="noopener noreferrer">Ballot SC-081v3</a>, which sets a schedule for reducing public TLS certificate maximum validity periods. The ballot describes a reduction that starts in March 2026 and ends in March 2029 at 47 days. DigiCert’s explainer gives the operational dates in plain form: <a href="https://www.digicert.com/blog/tls-certificate-lifetimes-will-officially-reduce-to-47-days" title="200 days from March 15, 2026, 100 days from March 15, 2027, and 47 days from March 15, 2029" target="_blank" rel="noopener noreferrer">200 days from March 15, 2026, 100 days from March 15, 2027, and 47 days from March 15, 2029</a>.</p>
<p>Once certificate lifetimes keep shrinking, the choice gets less theoretical. Performance and architecture still matter. The first operational question is simpler: how much certificate, TLS, reload, and protocol work do you want humans to keep doing?</p>
<p>Manual certificate management is finished as a serious plan. You can renew a 47-day certificate by hand in theory, the same way you can run production backups by dragging files into a folder. The failure mode is obvious, and it will arrive at the worst time.</p>
<p>All version-sensitive claims below were checked on July 2, 2026. HTTP/3 and ACME support are still moving enough that dates matter.</p>
<h2 id="the-short-answer">The short answer<a href="#the-short-answer" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Caddy is the low-ops choice. It gives you automatic HTTPS, sensible TLS defaults, and HTTP/3. For a small team, a product site, a dashboard, or a containerized service, Caddy usually gets you to the right end state fastest.</p>
<p>Nginx is the control choice. It has the highest ceiling for reverse proxying, load balancing, traffic shaping, and careful tuning. Native ACME closes the old certificate gap, but Nginx still expects you to understand the config model. Fair trade, if you want the control.</p>
<p>Apache is the compatibility choice. Pick it for <code>.htaccess</code>, legacy modules, shared-hosting compatibility, or old WordPress and PHP workflows that expect per-directory overrides. If HTTP/3 matters now, Apache is the weak fit here.</p>
<h2 id="architecture-decides-the-memory-curve">Architecture decides the memory curve<a href="#architecture-decides-the-memory-curve" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Apache 2.4 is built around Multi-Processing Modules. The official <a href="https://httpd.apache.org/docs/2.4/mpm.html" title="Apache MPM documentation" target="_blank" rel="noopener noreferrer">Apache MPM documentation</a> explains that Apache can use <code>prefork</code>, <code>worker</code>, or <code>event</code>, with only one MPM loaded at a time. <code>prefork</code> is process based, <code>worker</code> mixes processes and threads, and <code>event</code> is the modern default on most Unix-like systems.</p>
<p>Apache’s flexibility is useful, but it spreads the work around. You are choosing a process model, module set, virtual host structure, override behavior, and usually a PHP execution model.</p>
<p>Nginx uses a master process and worker processes. Its beginner guide says worker processes handle requests and that Nginx uses an <a href="https://nginx.org/en/docs/beginners_guide.html" title="event-based model with OS-dependent mechanisms" target="_blank" rel="noopener noreferrer">event-based model with OS-dependent mechanisms</a> to distribute work efficiently. That keeps connection handling cheap when the server is mostly moving bytes between clients and upstreams.</p>
<p>Caddy 2 is written in Go. The Caddy architecture docs describe it as a <a href="https://caddyserver.com/docs/architecture" title="single static binary with zero external dependencies" target="_blank" rel="noopener noreferrer">single static binary with zero external dependencies</a>, and Caddy’s HTTP stack sits on Go’s concurrency model. Deployment is simple. Concurrency is good. Under extreme reverse-proxy load, Go runtime behavior, garbage collection, and fewer low-level tuning knobs can matter.</p>
<p>For a reverse proxy pushing very high concurrency and strict tail-latency targets, Nginx still has the sharper toolset.</p>
<h2 id="http3-status-in-2026">HTTP/3 status in 2026<a href="#http3-status-in-2026" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>HTTP/3 runs over QUIC and UDP. Enabling it means the server must listen on UDP 443, the firewall must allow it, and the network path must pass QUIC cleanly.</p>
<p>Nginx has HTTP/3 support through <code>ngx_http_v3_module</code>. The current official docs say the module was introduced in <a href="https://nginx.org/en/docs/http/ngx_http_v3_module.html" title="1.25.0 and still describe it as experimental support" target="_blank" rel="noopener noreferrer">1.25.0 and still describe it as experimental support</a>. That label is not a panic button. It is a reason to test your clients, CDN, firewall, kernel, and observability stack before you call the rollout done.</p>
<p>Caddy supports HTTP/3 by default. Its global options documentation lists the default HTTP protocols as <a href="https://caddyserver.com/docs/caddyfile/options" title="h1 h2 h3" target="_blank" rel="noopener noreferrer"><code>h1 h2 h3</code></a>, and automatic HTTPS is part of the normal site-address flow.</p>
<p>Apache is behind here. Apache’s public <a href="https://httpd.apache.org/docs/2.4/mod/" title="2.4 module index" target="_blank" rel="noopener noreferrer">2.4 module index</a> lists <code>mod_http2</code> for HTTP/2 and <code>mod_proxy_http2</code> for proxying HTTP/2, but no HTTP/3 module. The <code>mod_http2</code> page describes <a href="https://httpd.apache.org/docs/2.4/mod/mod_http2.html" title="support for the HTTP/2 transport layer" target="_blank" rel="noopener noreferrer">support for the HTTP/2 transport layer</a>. As of July 2, 2026, Apache is a poor choice if HTTP/3 is a near-term requirement.</p>
<h2 id="certificate-automation-is-the-main-event">Certificate automation is the main event<a href="#certificate-automation-is-the-main-event" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The certificate lifetime schedule turns ACME from a nice-to-have into infrastructure. If renewals are boring and visible, 47-day certificates are manageable. If renewals depend on a wiki page and a tired person on a Friday, the system is already broken.</p>
<p>Caddy wins this section by design. Its automatic HTTPS docs say Caddy serves public DNS names over HTTPS using certificates from public ACME CAs such as Let’s Encrypt or ZeroSSL, keeps managed certificates renewed, and redirects HTTP to HTTPS automatically. If Caddy knows the public site name, <a href="https://caddyserver.com/docs/automatic-https" title="it serves the site over HTTPS automatically" target="_blank" rel="noopener noreferrer">it serves the site over HTTPS automatically</a>. Issuance, renewal, redirects, OCSP behavior, and protocol defaults are part of the normal path.</p>
<p>Nginx caught up in 2025. It <a href="https://blog.nginx.org/blog/native-support-for-acme-protocol" title="announced a preview release of ngx_http_acme_module" target="_blank" rel="noopener noreferrer">announced a preview release of <code>ngx_http_acme_module</code></a> on August 12, 2025. The announcement says the module provides directives for requesting, installing, and renewing certificates from Nginx configuration, and that it is a Rust-based dynamic module for both Nginx Open Source and NGINX One customers using NGINX Plus. The current module docs say <code>ngx_http_acme_module</code> implements <a href="https://nginx.org/en/docs/http/ngx_http_acme_module.html" title="ACMEv2" target="_blank" rel="noopener noreferrer">ACMEv2</a>, its source is on <a href="https://github.com/nginx/nginx-acme" title="GitHub" target="_blank" rel="noopener noreferrer">GitHub</a>, and a prebuilt <code>nginx-module-acme</code> package exists since 1.29.0. N.WTF’s Debian and Ubuntu builds include the official module by default; its <a href="https://n.wtf/changelog/" title="changelog records nginx-acme being added on November 29, 2025" target="_blank" rel="noopener noreferrer">changelog records <code>nginx-acme</code> being added on November 29, 2025</a>. Let’s Encrypt also <a href="https://letsencrypt.org/2025/09/11/native-acme-for-nginx" title="covered the official module" target="_blank" rel="noopener noreferrer">covered the official module</a>.</p>
<p>A practical Nginx ACME baseline starts with a persistent state directory. The module stores account data, certificates, and private keys there, so create it before reload and keep its permissions tight:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mkdir</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> /var/cache/nginx/letsencrypt</span></span></code></pre>
<p>The Nginx configuration is longer than Caddy’s, but it now keeps ACME, HTTP/2, and HTTP/3 inside Nginx:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="nginx"><code><span class="line"><span style="color:#F97583">resolver </span><span style="color:#E1E4E8">8.8.8.8:53 ipv6=off valid=5s;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">acme_issuer</span><span style="color:#E1E4E8"> letsencrypt {</span></span>
<span class="line"><span style="color:#F97583">    uri</span><span style="color:#E1E4E8">         https://acme-v02.api.letsencrypt.org/directory;</span></span>
<span class="line"><span style="color:#F97583">    contact</span><span style="color:#E1E4E8">     user@example.com;</span></span>
<span class="line"><span style="color:#F97583">    state_path</span><span style="color:#E1E4E8">  /var/cache/nginx/letsencrypt;</span></span>
<span class="line"><span style="color:#E1E4E8">    accept_terms_of_service;</span></span>
<span class="line"><span style="color:#F97583">    ssl_trusted_certificate </span><span style="color:#E1E4E8">/etc/ssl/certs/ca-certificates.crt;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">acme_shared_zone</span><span style="color:#E1E4E8"> zone=ngx_acme_shared:1M;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">server</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">80</span><span style="color:#79B8FF"> default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:80 </span><span style="color:#79B8FF">default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    server_name </span><span style="color:#E1E4E8">_;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    location</span><span style="color:#B392F0"> /.well-known/ </span><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#79B8FF"> 404</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    location</span><span style="color:#B392F0"> / </span><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#79B8FF"> 301</span><span style="color:#E1E4E8"> https://$host$request_uri;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">server</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8"> ssl </span><span style="color:#79B8FF">default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:443 ssl </span><span style="color:#79B8FF">default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    http2 </span><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8"> quic reuseport;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:443 quic reuseport;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    add_header </span><span style="color:#E1E4E8">Alt-Svc </span><span style="color:#9ECBFF">&#39;h3=&quot;:$</span><span style="color:#E1E4E8">server_port</span><span style="color:#9ECBFF">&quot;; ma=86400&#39;</span><span style="color:#E1E4E8"> always;</span></span>
<span class="line"><span style="color:#F97583">    add_header </span><span style="color:#E1E4E8">X-protocol $server_protocol always;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    server_name </span><span style="color:#E1E4E8">example.com;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    root </span><span style="color:#E1E4E8">/var/www/html;</span></span>
<span class="line"><span style="color:#F97583">    index </span><span style="color:#E1E4E8">index.html;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    ssl_protocols </span><span style="color:#E1E4E8">TLSv1.3;</span></span>
<span class="line"><span style="color:#F97583">    ssl_ecdh_curve </span><span style="color:#E1E4E8">X25519:prime256v1:secp384r1;</span></span>
<span class="line"><span style="color:#F97583">    ssl_prefer_server_ciphers </span><span style="color:#79B8FF">off</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    acme_certificate</span><span style="color:#E1E4E8"> letsencrypt;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    ssl_certificate </span><span style="color:#E1E4E8">$acme_certificate;</span></span>
<span class="line"><span style="color:#F97583">    ssl_certificate_key </span><span style="color:#E1E4E8">$acme_certificate_key;</span></span>
<span class="line"><span style="color:#F97583">    ssl_certificate_cache</span><span style="color:#E1E4E8"> max=2;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Keep <code>ssl_verify</code> on for production. The official module has an <code>ssl_verify off</code> switch, but using it disables verification of the ACME server certificate; with the CA bundle above, the safer default is to verify.</p>
<p>The current docs list HTTP-01 and TLS-ALPN-01 as accepted challenge types. DNS-01 is not listed there. If you need wildcard certificates, verify that path before assuming native Nginx ACME covers it.</p>
<p>Apache can do ACME too. <a href="https://httpd.apache.org/docs/2.4/mod/mod_md.html" title="mod_md" target="_blank" rel="noopener noreferrer"><code>mod_md</code></a> manages domains across virtual hosts and provides certificate provisioning via ACME. Its docs include examples with <code>MDomain</code>, ACME CA settings, challenge selection, renewal windows, and status integration.</p>
<p>Apache’s ACME problem is awareness and ceremony, not capability. You have to know <code>mod_md</code> exists, enable it, configure managed domains, understand virtual host interaction, and keep Apache’s TLS posture in order. In a 47-day certificate world, that is more human work than Caddy and a less direct workflow than modern Nginx.</p>
<h2 id="configuration-difficulty-is-the-visible-symptom">Configuration difficulty is the visible symptom<a href="#configuration-difficulty-is-the-visible-symptom" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Installation is routine in 2026. On Debian or Ubuntu, Apache and Nginx are package-manager basics. Caddy usually starts by adding the official repository, then it is also just a package install. The difference shows up in config.</p>
<p>Caddy can reverse proxy a public HTTPS site in three lines:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>example.com {</span></span>
<span class="line"><span>    reverse_proxy 127.0.0.1:3000</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>That gives you HTTPS automation and, by default, HTTP/1.1, HTTP/2, and HTTP/3. Caddy also has a native JSON configuration model and an admin API. The API docs say Caddy is configured through an HTTP administration endpoint, defaults to <code>localhost:2019</code>, and can load new config with zero downtime while rolling back failed loads.</p>
<p>Nginx is manageable, but explicit. Once HTTP/3 is included, the server block is no longer tiny:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="nginx"><code><span class="line"><span style="color:#F97583">server</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">80</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:80;</span></span>
<span class="line"><span style="color:#F97583">    server_name </span><span style="color:#E1E4E8">example.com;</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#79B8FF"> 301</span><span style="color:#E1E4E8"> https://$host$request_uri;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">server</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8"> ssl </span><span style="color:#79B8FF">default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:443 ssl </span><span style="color:#79B8FF">default_server</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">    # HTTP/3 over QUIC</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8"> quic reuseport;</span></span>
<span class="line"><span style="color:#F97583">    listen </span><span style="color:#E1E4E8">[::]:443 quic reuseport;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">    # HTTP/2</span></span>
<span class="line"><span style="color:#F97583">    http2 </span><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    server_name </span><span style="color:#E1E4E8">example.com;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    ssl_certificate </span><span style="color:#E1E4E8">/etc/nginx/ssl/example.com.crt;</span></span>
<span class="line"><span style="color:#F97583">    ssl_certificate_key </span><span style="color:#E1E4E8">/etc/nginx/ssl/example.com.key;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    add_header </span><span style="color:#E1E4E8">Alt-Svc </span><span style="color:#9ECBFF">&#39;h3=&quot;:$</span><span style="color:#E1E4E8">server_port</span><span style="color:#9ECBFF">&quot;; ma=86400&#39;</span><span style="color:#E1E4E8"> always;</span></span>
<span class="line"><span style="color:#F97583">    add_header </span><span style="color:#E1E4E8">X-protocol $server_protocol always;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    location</span><span style="color:#B392F0"> / </span><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#F97583">        proxy_pass </span><span style="color:#E1E4E8">http://127.0.0.1:3000;</span></span>
<span class="line"><span style="color:#F97583">        proxy_set_header </span><span style="color:#E1E4E8">Host $host;</span></span>
<span class="line"><span style="color:#F97583">        proxy_set_header </span><span style="color:#E1E4E8">X-Forwarded-For $proxy_add_x_forwarded_for;</span></span>
<span class="line"><span style="color:#F97583">        proxy_set_header </span><span style="color:#E1E4E8">X-Forwarded-Proto $scheme;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Readable, widely documented, easy to lint. The Nginx bargain is simple: you get a precise reverse proxy, but you own each decision unless a module or platform owns it for you.</p>
<p>Apache has the most moving parts:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="apache"><code><span class="line"><span style="color:#E1E4E8">MDomain example.com</span></span>
<span class="line"><span style="color:#E1E4E8">MDCertificateAgreement accepted</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#B392F0">VirtualHost</span><span style="color:#9ECBFF"> *:80</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#F97583">    ServerName</span><span style="color:#E1E4E8"> example.com</span></span>
<span class="line"><span style="color:#F97583">    Redirect</span><span style="color:#B392F0"> permanent</span><span style="color:#9ECBFF"> /</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#B392F0">VirtualHost</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#B392F0">VirtualHost</span><span style="color:#9ECBFF"> *:443</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#F97583">    ServerName</span><span style="color:#E1E4E8"> example.com</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    SSLEngine</span><span style="color:#B392F0"> on</span></span>
<span class="line"><span style="color:#E1E4E8">    Protocols h2 http/</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8"> acme-tls/</span><span style="color:#79B8FF">1</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">    ProxyPreserveHost</span><span style="color:#B392F0"> On</span></span>
<span class="line"><span style="color:#F97583">    ProxyPass</span><span style="color:#E1E4E8"> / http://</span><span style="color:#79B8FF">127</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">:</span><span style="color:#79B8FF">3000</span><span style="color:#E1E4E8">/</span></span>
<span class="line"><span style="color:#F97583">    ProxyPassReverse</span><span style="color:#E1E4E8"> / http://</span><span style="color:#79B8FF">127</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">:</span><span style="color:#79B8FF">3000</span><span style="color:#E1E4E8">/</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#B392F0">VirtualHost</span><span style="color:#E1E4E8">&gt;</span></span></code></pre>
<p>Apache’s power is real, but the surface area is larger. <code>httpd.conf</code>, virtual host inheritance, MPM choice, <code>.htaccess</code>, <code>mod_ssl</code>, <code>mod_proxy</code>, <code>mod_md</code>, and per-directory behavior all become part of the job.</p>
<h2 id="performance-without-fake-benchmark-theater">Performance, without fake benchmark theater<a href="#performance-without-fake-benchmark-theater" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Nginx is still the ceiling for high-concurrency reverse proxying. Its event-driven model, mature upstream handling, low memory footprint, and tuning surface are why it remains common at the edge.</p>
<p>Old comparison posts often underrate Caddy. Static file serving and ordinary reverse proxy workloads are fine. For most product sites, APIs, admin panels, and internal tools, app latency and database latency will dominate the difference.</p>
<p>The Caddy caveat lives at the far end: very high concurrency, strict memory budgets, and tail latency under sustained proxy load. Go’s runtime and garbage collector are part of the operating model. Nginx gives you more control when that level of tuning matters.</p>
<p>Apache can perform well with the event MPM, but legacy shape still matters. The prefork model remains common in older PHP stacks and shared-hosting environments, and prefork is expensive per connection. <code>.htaccess</code> can also force per-directory checks at request time. Useful, but not free.</p>
<p>Market share should inform your risk model, not decide the winner. W3Techs’ web server survey in late June 2026 showed <a href="https://w3techs.com/technologies/overview/web_server" title="Nginx at 31.8%, Apache at 23.2%, and Caddy at 0.2%" target="_blank" rel="noopener noreferrer">Nginx at 31.8%, Apache at 23.2%, and Caddy at 0.2%</a> of websites whose web server W3Techs knows. Nginx and Apache have deeper operational folklore. Caddy has less market share, but its defaults remove work that older stacks still hand to the operator.</p>
<h2 id="apaches-real-moat-is-htaccess">Apache’s real moat is <code>.htaccess</code><a href="#apaches-real-moat-is-htaccess" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Apache still owns one use case the others do not replace cleanly: <code>.htaccess</code>.</p>
<p>For shared hosting, that matters. A provider can let customers change rewrite rules, authentication, redirects, cache headers, PHP behavior, and per-directory settings without main server config access. WordPress, legacy PHP, and control-panel hosting grew around that model.</p>
<p>The same mechanism has a cost. <code>.htaccess</code> moves configuration out of one central reviewable file and into the document tree. Apache may need to check directories for override files on requests.</p>
<p>If you run a modern VPS, container, or single-tenant app server, <code>.htaccess</code> is usually a liability. If you run a shared host or a legacy app that expects it, it may be the whole reason Apache is still the right answer.</p>
<h2 id="nginx-has-a-community-split-to-watch">Nginx has a community split to watch<a href="#nginx-has-a-community-split-to-watch" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Nginx is still a strong default. Its ecosystem is less unified than it used to be.</p>
<p>F5 acquired NGINX in 2019, and the boundary between open source Nginx, NGINX Plus, and NGINX One remains a recurring operator discussion. In February 2024, Maxim Dounin started freenginx, whose site describes it as an effort to preserve free and open Nginx development. Angie is another Nginx-family project; its own site says the open source Angie server was created as a fork of Nginx. In 2026, “Nginx” might mean open source Nginx, NGINX Plus, freenginx, Angie, OpenResty, <a href="https://n.wtf/" title="N.WTF" target="_blank" rel="noopener noreferrer">N.WTF</a> packages, or a vendor platform built around Nginx semantics.</p>
<h2 id="other-servers-worth-knowing">Other servers worth knowing<a href="#other-servers-worth-knowing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>LiteSpeed and OpenLiteSpeed deserve a look for WordPress-heavy deployments, especially where LSCache is central. HAProxy is excellent when the job is load balancing, TLS termination, traffic routing, health checks, and proxy behavior. Traefik fits container-native routing well when your world is Docker labels, Kubernetes ingress, service discovery, and automatic Let’s Encrypt for many short-lived services. They are narrower picks than Apache, Nginx, and Caddy for a general-purpose VPS web server.</p>
<h2 id="decision-guide">Decision guide<a href="#decision-guide" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Choose Caddy when:</p>
<ul>
<li>you want HTTPS and HTTP/3 as defaults</li>
<li>your team is small and ops time is scarce</li>
<li>you deploy services frequently and like API-driven reloads</li>
<li>you value simple config over maximum tuning depth</li>
</ul>
<p>Choose Nginx when:</p>
<ul>
<li>you run high-traffic reverse proxy workloads</li>
<li>you need precise upstream, caching, buffering, or rate-limit behavior</li>
<li>your team already knows Nginx well</li>
<li>you want native ACME but still prefer explicit server configuration</li>
</ul>
<p>Choose Apache when:</p>
<ul>
<li><code>.htaccess</code> support is required</li>
<li>you are operating a shared-hosting or legacy PHP environment</li>
<li>the application depends on Apache modules or old deployment assumptions</li>
<li>HTTP/3 can wait</li>
</ul>
<p>For a new 2026 VPS deployment, my default is Caddy for simple apps, Nginx for serious proxy and tuning work, and Apache when Apache compatibility is part of the requirement.</p>





































































































<table><thead><tr><th>Dimension</th><th>Apache 2.4</th><th>Nginx</th><th>Caddy 2</th></tr></thead><tbody><tr><td>First release / language</td><td>1995 / C</td><td>2004 / C</td><td>2015 / Go</td></tr><tr><td>Architecture</td><td>MPM process/thread model</td><td>Event-driven workers</td><td>Go runtime and goroutines</td></tr><tr><td>HTTP/3</td><td>No public Apache 2.4 module</td><td>Yes, official module docs still say experimental</td><td>Yes, default protocol set includes <code>h3</code></td></tr><tr><td>ACME automation</td><td><code>mod_md</code>, capable but manual</td><td><code>ngx_http_acme_module</code>, native ACMEv2</td><td>Native automatic HTTPS</td></tr><tr><td>TLS defaults</td><td>Operator-managed</td><td>Operator-managed</td><td>Secure by default for common sites</td></tr><tr><td>Installation</td><td>Easy package install</td><td>Easy package install</td><td>Easy after adding official repo</td></tr><tr><td>Configuration difficulty</td><td>Highest</td><td>Medium</td><td>Lowest</td></tr><tr><td>Config and reload</td><td><code>httpd.conf</code>, vhosts, <code>.htaccess</code>, reload</td><td><code>nginx.conf</code>, graceful reload</td><td>Caddyfile or JSON API, zero-downtime reload</td></tr><tr><td>High-concurrency proxying</td><td>Acceptable with event MPM, weak in legacy prefork stacks</td><td>Best fit</td><td>Good for most sites</td></tr><tr><td>Memory profile</td><td>Can be high</td><td>Very low</td><td>Moderate</td></tr><tr><td>Reverse proxy / load balancing</td><td>Capable, less common as the edge default</td><td>Excellent</td><td>Good</td></tr><tr><td><code>.htaccess</code></td><td>Yes</td><td>No</td><td>No</td></tr><tr><td>Learning curve</td><td>Steep</td><td>Moderate</td><td>Gentle</td></tr><tr><td>W3Techs share, late June 2026</td><td>23.2%</td><td>31.8%</td><td>0.2%</td></tr><tr><td>Best fit</td><td>Legacy apps, shared hosting, Apache module stacks</td><td>High-traffic reverse proxy and tuned edge services</td><td>Small teams, fast launches, container and VPS apps</td></tr></tbody></table>
<h2 id="vps-control-matters-for-http3">VPS control matters for HTTP/3<a href="#vps-control-matters-for-http3" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>HTTP/3 needs UDP 443. Certificate automation needs reliable inbound validation or correct DNS automation. Serious web-server tuning needs root access, firewall control, kernel and sysctl visibility, logs, and a network where you can test the actual path.</p>
<p>A VPS is cleaner than shared hosting for this. On Riven Cloud, every plan is an unmanaged KVM VPS with full root access, a dedicated IPv4 address, a <code>/64</code> IPv6 block, NVMe SSD storage, daily backups, and a 1 Gbps network port. If you are choosing a web server for users in mainland China, test the route from <a href="https://sa.net/network/" title="Tokyo or Singapore">Tokyo or Singapore</a> first, then compare plans on the <a href="https://sa.net/pricing/" title="pricing page">pricing page</a>.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The 2026 web server choice starts with automation. Caddy gives you the shortest path to a correct modern HTTPS setup. Nginx gives you the highest ceiling when traffic shape and proxy control matter. Apache remains the compatibility king for <code>.htaccess</code>, shared hosting, and old module ecosystems, but HTTP/3 absence and heavier configuration make it harder to justify for new deployments.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Nginx" term="Nginx"/>
        <category label="Apache" term="Apache"/>
        <category label="Caddy" term="Caddy"/>
        <category label="Web Server" term="Web Server"/>
        <category label="HTTP/3" term="HTTP/3"/>
        <category label="ACME" term="ACME"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Secure SSH on a VPS Without Locking Yourself Out]]></title>
        <id>https://sa.net/blog/secure-ssh-on-a-vps/</id>
        <link href="https://sa.net/blog/secure-ssh-on-a-vps/"/>
        <updated>2026-07-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A practical SSH hardening guide for Linux VPS operators: add keys first, disable password login, move the port safely, configure fail2ban, and tighten the firewall.]]></summary>
        <content type="html"><![CDATA[<p>SSH is usually the first service that internet scanners touch. Put a fresh VPS online with password login enabled on port 22, and the authentication log will often start collecting junk within hours.</p>
<p>The fix is mostly order. Add and test an SSH key first. Then disable password login, move the listener only after the new port is open, configure fail2ban, and close whatever you no longer need. Key-only SSH does the serious work. A custom port makes the logs quieter. fail2ban blocks noisy clients, but it does not make weak authentication safe.</p>
<p>Use one rule for the whole job: build the new entrance before closing the old one. Do not disable a login method, restart SSH, or remove a firewall rule until the replacement path works from a new terminal.</p>
<p>This is written for people running an unmanaged Linux VPS. If you are still deciding whether root access is worth the operational work, read <a href="https://sa.net/blog/vps-vs-shared-hosting/" title="VPS vs shared hosting">VPS vs shared hosting</a> first.</p>
<h2 id="before-you-start-dont-lock-yourself-out">Before you start: don’t lock yourself out<a href="#before-you-start-dont-lock-yourself-out" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Keep one working SSH session open the whole time. Do not close it just because the last command succeeded. SSH restarts usually leave existing sessions alone, and that terminal is your way back in if the next login test fails.</p>
<p>Run the OpenSSH server config test before every restart:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sshd</span><span style="color:#79B8FF"> -t</span></span></code></pre>
<p>The <code>-t</code> flag makes <code>sshd</code> check the configuration and keys, then exit without starting a new daemon, as documented in the <a href="https://man.openbsd.org/sshd" title="OpenSSH sshd manual" target="_blank" rel="noopener noreferrer">OpenSSH sshd manual</a>. If it prints an error, fix that first.</p>
<p>After each change, open a second terminal and log in again. Move on only after the new session works.</p>
<p>The service name depends on the distro:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># Debian and Ubuntu</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> ssh</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># AlmaLinux, Rocky Linux, and CentOS Stream</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> sshd</span></span></code></pre>
<h2 id="step-1-generate-an-ssh-key-and-add-it-first">Step 1: generate an SSH key and add it first<a href="#step-1-generate-an-ssh-key-and-add-it-first" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Start with the key because every later step depends on key login already working. OpenSSH supports several key types through <code>ssh-keygen</code>; for a normal modern server, Ed25519 is a sensible default. Use RSA 4096 only when an older server or client cannot use Ed25519. The <a href="https://man.openbsd.org/ssh-keygen" title="ssh-keygen manual" target="_blank" rel="noopener noreferrer">ssh-keygen manual</a> documents both key types.</p>
<p>On macOS or Linux:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh-keygen</span><span style="color:#79B8FF"> -t</span><span style="color:#9ECBFF"> ed25519</span><span style="color:#79B8FF"> -C</span><span style="color:#9ECBFF"> &quot;you@host&quot;</span></span></code></pre>
<p>The private key is usually saved as <code>~/.ssh/id_ed25519</code>. The public key is <code>~/.ssh/id_ed25519.pub</code>.</p>
<p>On Windows, use the built-in OpenSSH client from PowerShell:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="powershell"><code><span class="line"><span style="color:#E1E4E8">ssh</span><span style="color:#F97583">-</span><span style="color:#E1E4E8">keygen </span><span style="color:#F97583">-</span><span style="color:#E1E4E8">t ed25519 </span><span style="color:#F97583">-</span><span style="color:#E1E4E8">C </span><span style="color:#9ECBFF">&quot;you@host&quot;</span></span></code></pre>
<p>PuTTY users can generate the key with PuTTYgen, then export the public key in OpenSSH format. If you log in with PuTTY, keep the private key in PuTTY’s format. If you log in from Terminal, PowerShell, or WSL, keep the private key in OpenSSH format.</p>
<p>Set a passphrase when <code>ssh-keygen</code> asks. It protects the private key if your laptop is stolen or a backup leaks. <code>ssh-agent</code> keeps the key usable without making you type the passphrase for every login:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">eval</span><span style="color:#9ECBFF"> &quot;$(</span><span style="color:#B392F0">ssh-agent</span><span style="color:#79B8FF"> -s</span><span style="color:#9ECBFF">)&quot;</span></span>
<span class="line"><span style="color:#B392F0">ssh-add</span><span style="color:#9ECBFF"> ~/.ssh/id_ed25519</span></span></code></pre>
<p>On Windows PowerShell:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="powershell"><code><span class="line"><span style="color:#79B8FF">Start-Service</span><span style="color:#E1E4E8"> ssh</span><span style="color:#F97583">-</span><span style="color:#E1E4E8">agent</span></span>
<span class="line"><span style="color:#E1E4E8">ssh</span><span style="color:#F97583">-</span><span style="color:#E1E4E8">add $</span><span style="color:#79B8FF">env:</span><span style="color:#E1E4E8">USERPROFILE\.ssh\id_ed25519</span></span></code></pre>
<p>For a new Riven Cloud server, add the key before you install the OS.</p>
<p>Option 1: generate the key locally. In the Riven Cloud control panel, go to SSH keys, click Add Key, paste the contents of your <code>.pub</code> file, give it a clear name, and select it during OS install or reinstall.</p>
<p>Option 2: generate the key in the control panel. Go to <code>SSH key</code> &gt; <code>Add Key</code> &gt; <code>Generate Key Pair</code>, download the private key, and store it somewhere safe. Choose OpenSSH format for Terminal or PowerShell, or PuTTY format for PuTTY on Windows. Select that key when you install the server.</p>
<p>For an already-running server where password login still works, push the public key with <code>ssh-copy-id</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh-copy-id</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> ~/.ssh/id_ed25519.pub</span><span style="color:#9ECBFF"> root@SERVER_IP</span></span></code></pre>
<p>Test the key from a new terminal:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> ~/.ssh/id_ed25519</span><span style="color:#9ECBFF"> root@SERVER_IP</span></span></code></pre>
<p>That login must work before you continue. If it fails, stop here and check the key path, username, public key, file permissions, or control panel selection. Leave password login alone until key login works.</p>
<h2 id="step-2-disable-password-login-and-lock-down-root">Step 2: disable password login and lock down root<a href="#step-2-disable-password-login-and-lock-down-root" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Password login is what turns random SSH scans into a guessing attack. Once key login works, disable password authentication and keyboard-interactive authentication. For this pass, keep root key login available with <code>PermitRootLogin prohibit-password</code>: root cannot use a password, but a valid key still works. The options are documented in <a href="https://man.openbsd.org/sshd_config" title="sshd_config(5)" target="_blank" rel="noopener noreferrer">sshd_config(5)</a>.</p>
<p>Create a drop-in file:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> install</span><span style="color:#79B8FF"> -d</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/ssh/sshd_config.d</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/ssh/sshd_config.d/10-hardening.conf</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;EOF&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">PubkeyAuthentication yes</span></span>
<span class="line"><span style="color:#9ECBFF">PasswordAuthentication no</span></span>
<span class="line"><span style="color:#9ECBFF">KbdInteractiveAuthentication no</span></span>
<span class="line"><span style="color:#9ECBFF">PermitRootLogin prohibit-password</span></span>
<span class="line"><span style="color:#9ECBFF">EOF</span></span></code></pre>
<p>Most current Debian, Ubuntu, AlmaLinux, Rocky Linux, and CentOS Stream images include <code>/etc/ssh/sshd_config.d/*.conf</code> from the main <code>sshd_config</code>. If your image is unusual, check the main file for this line:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Include /etc/ssh/sshd_config.d/*.conf</span></span></code></pre>
<p>Validate first:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sshd</span><span style="color:#79B8FF"> -t</span></span></code></pre>
<p>Restart SSH:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># Debian and Ubuntu</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> ssh</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># AlmaLinux, Rocky Linux, and CentOS Stream</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> sshd</span></span></code></pre>
<p>Open a new terminal and test key login again:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> ~/.ssh/id_ed25519</span><span style="color:#9ECBFF"> root@SERVER_IP</span></span></code></pre>
<p>Then confirm password-only login is refused from a new terminal:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> PubkeyAuthentication=no</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> IdentitiesOnly=yes</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> PreferredAuthentications=keyboard-interactive,password</span><span style="color:#9ECBFF"> root@SERVER_IP</span></span></code></pre>
<p>The server should reject the attempt without showing a password prompt. If key login still works and that password-only attempt fails, the biggest SSH hardening win is already done.</p>
<p>A non-root sudo user plus <code>PermitRootLogin no</code> is a stronger long-term setup, but do it only after that user has been tested. This guide keeps it optional because this is where people lock themselves out. If you make that change, create the sudo user, add its key, test <code>sudo</code>, then disable root login.</p>
<h2 id="step-3-change-the-ssh-port-for-noise-reduction">Step 3: change the SSH port for noise reduction<a href="#step-3-change-the-ssh-port-for-noise-reduction" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Moving SSH away from port 22 is housekeeping. It does not make SSH stronger, and it will not save a server that still accepts weak passwords. It mainly cuts down hits from scanners that only try the default port. Do it after key-only login works, and treat it as log cleanup.</p>
<p>This example uses port <code>2222</code>. If you choose another port, use the same value in SSH, the firewall, and fail2ban.</p>
<p>Open the new port in the firewall before you restart SSH.</p>
<p>On AlmaLinux, Rocky Linux, and CentOS Stream, <code>firewalld</code> is commonly enabled on VPS templates and new inbound ports are closed by default. Add <code>2222/tcp</code> first:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --permanent</span><span style="color:#79B8FF"> --add-port=2222/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --reload</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --list-ports</span></span></code></pre>
<p>The <code>firewall-cmd</code> syntax is documented in the <a href="https://firewalld.org/documentation/man-pages/firewall-cmd.html" title="firewalld command manual" target="_blank" rel="noopener noreferrer">firewalld command manual</a>.</p>
<p>On Ubuntu or Debian, UFW may or may not be enabled. Check before you add rules:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>If UFW is active, allow the new port:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 2222/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>The <a href="https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html" title="Ubuntu ufw manual" target="_blank" rel="noopener noreferrer">Ubuntu ufw manual</a> documents <code>allow</code>, <code>delete</code>, and status commands.</p>
<p>Now change the SSH listener. On most servers, use a temporary dual-port drop-in so port 22 stays available while you test port 2222:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/ssh/sshd_config.d/20-port.conf</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;EOF&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">Port 22</span></span>
<span class="line"><span style="color:#9ECBFF">Port 2222</span></span>
<span class="line"><span style="color:#9ECBFF">EOF</span></span></code></pre>
<p>Validate and restart:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sshd</span><span style="color:#79B8FF"> -t</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># Debian and Ubuntu</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> ssh</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># AlmaLinux, Rocky Linux, and CentOS Stream</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> sshd</span></span></code></pre>
<p>Open a new terminal and test the new port:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ssh</span><span style="color:#79B8FF"> -p</span><span style="color:#79B8FF"> 2222</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> ~/.ssh/id_ed25519</span><span style="color:#9ECBFF"> root@SERVER_IP</span></span></code></pre>
<p>Remove port 22 from the SSH listener only after port 2222 works:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/ssh/sshd_config.d/20-port.conf</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;EOF&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">Port 2222</span></span>
<span class="line"><span style="color:#9ECBFF">EOF</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sshd</span><span style="color:#79B8FF"> -t</span></span></code></pre>
<p>Restart SSH again with the correct service name for your distro. Then open one more new session on port 2222.</p>
<h3 id="ubuntu-and-debian-check-sshsocket">Ubuntu and Debian: check ssh.socket<a href="#ubuntu-and-debian-check-sshsocket" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Some Debian or Ubuntu systems use <code>ssh.socket</code>. When socket activation is active, systemd owns the listening socket, so the <code>Port</code> line in <code>sshd_config</code> may not control the initial listener. Check it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">systemctl</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> ssh.socket</span></span></code></pre>
<p>If it is active and listening, override the socket instead of relying only on <code>20-port.conf</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> edit</span><span style="color:#9ECBFF"> ssh.socket</span></span></code></pre>
<p>Use both ports for the first test:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ini"><code><span class="line"><span style="color:#B392F0">[Socket]</span></span>
<span class="line"><span style="color:#F97583">ListenStream</span><span style="color:#E1E4E8">=</span></span>
<span class="line"><span style="color:#F97583">ListenStream</span><span style="color:#E1E4E8">=22</span></span>
<span class="line"><span style="color:#F97583">ListenStream</span><span style="color:#E1E4E8">=2222</span></span></code></pre>
<p>Then validate SSH config, reload systemd, and restart the socket:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> sshd</span><span style="color:#79B8FF"> -t</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> daemon-reload</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> restart</span><span style="color:#9ECBFF"> ssh.socket</span></span></code></pre>
<p>After <code>ssh -p 2222 root@SERVER_IP</code> works from a new terminal, edit the socket again and leave only this listener:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ini"><code><span class="line"><span style="color:#B392F0">[Socket]</span></span>
<span class="line"><span style="color:#F97583">ListenStream</span><span style="color:#E1E4E8">=</span></span>
<span class="line"><span style="color:#F97583">ListenStream</span><span style="color:#E1E4E8">=2222</span></span></code></pre>
<p>The <code>ListenStream</code> behavior comes from the <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html" title="systemd.socket documentation" target="_blank" rel="noopener noreferrer">systemd.socket documentation</a>. Keep the order boring: add the new listener, test it, then remove the old listener.</p>
<h3 id="selinux-note">SELinux note<a href="#selinux-note" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Riven Cloud default Linux templates ship with SELinux disabled, so those templates do not need an SELinux port change.</p>
<p>If you enabled SELinux yourself on a RHEL-family server, allow the new SSH port before restart:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> policycoreutils-python-utils</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> semanage</span><span style="color:#9ECBFF"> port</span><span style="color:#79B8FF"> -a</span><span style="color:#79B8FF"> -t</span><span style="color:#9ECBFF"> ssh_port_t</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> tcp</span><span style="color:#79B8FF"> 2222</span></span></code></pre>
<p>The <code>semanage port</code> command is documented in the <a href="https://man7.org/linux/man-pages/man8/semanage-port.8.html" title="semanage-port manual" target="_blank" rel="noopener noreferrer">semanage-port manual</a>. Do not run it unless SELinux is actually enabled.</p>
<p>Once you can log in on port 2222, remove port 22 from the firewall.</p>
<p>For firewalld, the default SSH opening is usually the <code>ssh</code> service, not a raw port rule:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --permanent</span><span style="color:#79B8FF"> --remove-service=ssh</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --permanent</span><span style="color:#79B8FF"> --remove-port=22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --reload</span></span></code></pre>
<p>For UFW:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> delete</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 22/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> delete</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> OpenSSH</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>Run the delete command that matches what your firewall actually shows. Never remove port 22 before a new session on port 2222 works.</p>
<h2 id="step-4-stop-brute-force-with-fail2ban">Step 4: stop brute force with fail2ban<a href="#step-4-stop-brute-force-with-fail2ban" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>fail2ban is useful after SSH authentication is already sane. Riven Cloud’s default Linux templates ship with fail2ban preinstalled. Verify it anyway:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> fail2ban-client</span><span style="color:#79B8FF"> --version</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> fail2ban-client</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> sshd</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> fail2ban</span></span></code></pre>
<p>If it is missing on a custom image, install it:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># Debian and Ubuntu</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> fail2ban</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># AlmaLinux, Rocky Linux, and CentOS Stream</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> epel-release</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> fail2ban</span></span></code></pre>
<p>Put local overrides under <code>/etc/fail2ban/jail.d/</code>. Do not edit <code>jail.conf</code> directly; fail2ban’s upstream configuration is meant to be overridden locally, as shown by its <a href="https://github.com/fail2ban/fail2ban/blob/master/config/jail.conf" title="default jail configuration" target="_blank" rel="noopener noreferrer">default jail configuration</a>.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> tee</span><span style="color:#9ECBFF"> /etc/fail2ban/jail.d/ssh.conf</span><span style="color:#F97583"> &gt;</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#F97583"> &lt;&lt;</span><span style="color:#9ECBFF">&#39;EOF&#39;</span></span>
<span class="line"><span style="color:#9ECBFF">[sshd]</span></span>
<span class="line"><span style="color:#9ECBFF">enabled = true</span></span>
<span class="line"><span style="color:#9ECBFF">port = 2222</span></span>
<span class="line"><span style="color:#9ECBFF">backend = systemd</span></span>
<span class="line"><span style="color:#9ECBFF">maxretry = 5</span></span>
<span class="line"><span style="color:#9ECBFF">findtime = 10m</span></span>
<span class="line"><span style="color:#9ECBFF">bantime = 1h</span></span>
<span class="line"><span style="color:#9ECBFF">EOF</span></span></code></pre>
<p>The <code>port</code> value must match the port from Step 3. If fail2ban watches port 22 while SSH listens on 2222, the jail is watching the wrong place.</p>
<p>Modern Debian, Ubuntu, AlmaLinux, Rocky Linux, and CentOS Stream systems normally log SSH authentication through the systemd journal, so <code>backend = systemd</code> is a good default. If your image writes only to traditional log files, use a log file backend instead.</p>
<p>Enable and start fail2ban:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> systemctl</span><span style="color:#9ECBFF"> enable</span><span style="color:#79B8FF"> --now</span><span style="color:#9ECBFF"> fail2ban</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> fail2ban-client</span><span style="color:#9ECBFF"> status</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> fail2ban-client</span><span style="color:#9ECBFF"> status</span><span style="color:#9ECBFF"> sshd</span></span></code></pre>
<p>The <code>sshd</code> status output shows the jail, current failures, and banned IP addresses. With key-only auth in place, brute force attempts cannot log in by guessing a password. fail2ban still helps by blocking noisy scanners and keeping abusive clients from hitting your logs all day.</p>
<h2 id="step-5-tighten-the-firewall-and-extra-ssh-knobs">Step 5: tighten the firewall and extra SSH knobs<a href="#step-5-tighten-the-firewall-and-extra-ssh-knobs" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>After SSH is stable on the new port, keep the firewall plain. Deny inbound traffic by default, then open only what the server actually serves.</p>
<p>For a web server, that often means SSH plus HTTP and HTTPS:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># firewalld</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --permanent</span><span style="color:#79B8FF"> --add-service=http</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --permanent</span><span style="color:#79B8FF"> --add-service=https</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --reload</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> firewall-cmd</span><span style="color:#79B8FF"> --list-all</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"># UFW</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> deny</span><span style="color:#9ECBFF"> incoming</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> default</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> outgoing</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 2222/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 80/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> allow</span><span style="color:#9ECBFF"> 443/tcp</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ufw</span><span style="color:#9ECBFF"> status</span></span></code></pre>
<p>RHEL-family VPS templates are a useful model here: with firewalld enabled, SSH is allowed and random ports are closed. Keep that habit. Add a port when a service needs it, and remove the port when the service is gone.</p>
<p>Do not mix SSH cleanup with unrelated performance work. If a site feels slow after hardening, diagnose that separately with a page-load and server check such as <a href="https://sa.net/blog/why-is-my-website-slow/" title="why is my website slow">why is my website slow</a>.</p>
<p>A few optional SSH settings can make abuse less noisy:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>MaxAuthTries 3</span></span>
<span class="line"><span>LoginGraceTime 30</span></span>
<span class="line"><span>AllowUsers deploy admin</span></span>
<span class="line"><span># or:</span></span>
<span class="line"><span># AllowGroups ssh-users</span></span></code></pre>
<p>Use <code>AllowUsers</code> or <code>AllowGroups</code> only after the named user or group has been tested in a new session. The same rule still applies: test the new path before closing the old one.</p>
<h2 id="what-actually-protects-you-when-you-secure-ssh-on-a-vps">What actually protects you when you secure SSH on a VPS<a href="#what-actually-protects-you-when-you-secure-ssh-on-a-vps" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Do not treat every control as equal. They are not doing the same job.</p>
<ol>
<li>Key-only authentication with password login disabled removes password guessing from the attack path.</li>
<li>A minimal firewall and a non-root sudo user reduce exposure and limit blast radius.</li>
<li>fail2ban cuts log noise and blocks abusive scanners.</li>
<li>Changing the SSH port reduces noise, not risk by itself.</li>
</ol>
<p>Treat the port change as cleanup, not the main defense. A server on port 2222 with password login enabled is still a weak SSH server. A server on port 22 with passwords disabled and strong keys is already much harder to brute force.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The safe order is simple: add and test the key, disable passwords, open and test the new port, configure fail2ban, then remove anything you no longer need.</p>
<p>Riven Cloud’s Linux templates ship with fail2ban preinstalled, and the control panel can add SSH keys during deployment. If you need an unmanaged KVM VPS with root access, daily backups, and Tokyo or Singapore locations, you can <a href="https://sa.net/pricing/" title="view VPS plans">view VPS plans</a>.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Security" term="Security"/>
        <category label="VPS" term="VPS"/>
        <category label="SSH" term="SSH"/>
        <category label="Infrastructure" term="Infrastructure"/>
        <category label="Linux" term="Linux"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[VPS vs Shared Hosting: Which Do You Need?]]></title>
        <id>https://sa.net/blog/vps-vs-shared-hosting/</id>
        <link href="https://sa.net/blog/vps-vs-shared-hosting/"/>
        <updated>2026-06-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A practical comparison of VPS and shared hosting for site owners choosing between convenience, control, cost, and operations work.]]></summary>
        <content type="html"><![CDATA[<p>VPS vs shared hosting is usually framed as a ladder: start cheap, then move up when you outgrow it. That is partly true, but it misses the more useful tension.</p>
<p>The real tradeoff is control versus convenience.</p>
<p>Shared hosting gives you a prepared environment. You upload a site, click around a control panel, and let the provider handle most of the server work. A VPS gives you a server-like environment with root access. You decide what runs, how it is configured, and how it is maintained.</p>
<p>Neither model is automatically better. A portfolio site, small WordPress site, or local business page may be happier on shared hosting for years. A custom app, API, bot, database-backed service, or traffic-heavy site may become awkward on shared hosting quickly. The difference between VPS and shared hosting is less about status and more about your stage, skill level, and tolerance for operations work.</p>
<h2 id="what-shared-hosting-and-vps-actually-are">What shared hosting and VPS actually are<a href="#what-shared-hosting-and-vps-actually-are" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The simplest analogy is housing.</p>
<p>Shared hosting is like renting a room in a shared apartment. The kitchen, utilities, locks, and building rules are handled for you. You get a small private area, but the building owner decides what appliances are allowed, how noisy the neighbors can be, and what you can change.</p>
<p>A VPS is closer to having your own small house. The land still belongs to a larger provider, but your environment is isolated. You decide what to install, how to wire the rooms, and when to renovate. You also deal with the consequences when something leaks at 3am.</p>
<p>Underneath the analogy, shared hosting places many customer sites inside a provider-managed environment. CPU, memory, disk, web server configuration, PHP versions, mail handling, SSL tooling, backups, and control panel behavior are mostly standardized.</p>
<p>A VPS uses virtualization to give you an isolated operating system instance. You usually get a fixed amount of vCPU, RAM, disk, transfer, and network access. The provider owns the physical host and network. You own the software stack inside your virtual server.</p>
<h2 id="shared-hosting-strengths-and-limits">Shared hosting: strengths and limits<a href="#shared-hosting-strengths-and-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Shared hosting is popular because it removes friction.</p>
<p>For a simple site, that matters. You do not need to install a web server, configure PHP-FPM, set up a firewall, harden SSH, schedule backups, or think about unattended security upgrades. The provider gives you a panel, a file manager or SFTP access, database creation tools, DNS helpers, SSL buttons, and often one-click installers.</p>
<p>That model is a good fit for many sites:</p>
<ul>
<li>static HTML/CSS pages</li>
<li>small WordPress sites</li>
<li>basic PHP apps</li>
<li>landing pages</li>
<li>small business sites</li>
<li>hobby projects where uptime is useful but not mission critical</li>
</ul>
<p>The best part is not the low price. It is the low mental load. You can build the site and avoid becoming the server administrator.</p>
<p>The limits are real, though. Many shared hosts do not provide shell access, or they provide a restricted shell that is not useful for serious deployment work. The stack is usually centered on PHP, static files, MySQL or MariaDB, and a provider-approved control panel. If your app needs Node.js workers, Python services, background queues, custom daemons, Docker, Redis, PostgreSQL, or long-running jobs, shared hosting may fight you.</p>
<p>Resource limits can also be opaque. A plan may advertise generous storage or transfer while enforcing CPU seconds, process counts, memory caps, inode limits, database query limits, cron frequency limits, or request throttles elsewhere. Those caps are not always bad. They protect the shared platform. They also mean your site can hit a wall without an obvious upgrade path.</p>
<p>Noisy neighbors are the other classic problem. Since many sites share the same underlying platform, another customer can affect performance if isolation is weak or the node is overloaded. Good shared hosts manage this carefully. Cheap shared hosts sometimes do not.</p>
<p>Shared hosting also tends to create lock-in around the panel and workflow. You may get used to provider-specific backup tools, staging tools, mailboxes, SSL buttons, and file deployment patterns. Moving later can take more work than expected.</p>
<h2 id="vps-strengths-and-limits">VPS: strengths and limits<a href="#vps-strengths-and-limits" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A VPS gives you control first.</p>
<p>With root access, you can install the stack your app actually needs: Nginx or Apache, Caddy, Node.js, Python, Go binaries, PHP, PostgreSQL, MySQL, Redis, Docker, queue workers, search tools, observability agents, cron jobs, private APIs, reverse proxies, VPN services, and internal tools.</p>
<p>That flexibility matters when the workload is no longer a plain website. A dashboard with background imports, a bot that listens all day, a webhook receiver, a small SaaS app, a staging server, or a private API all fit a VPS better than a restricted shared account.</p>
<p>Resources are usually more predictable. If the plan says 2 vCPU and 4 GB RAM, you can reason about capacity in a way that is harder on shared hosting. Virtualization is still shared at the physical host level, and providers vary, but the boundary is clearer. You can watch CPU, RAM, disk I/O, network use, and process behavior yourself.</p>
<p>Scaling is also more direct. You can move from a smaller VPS to a larger one, add a cache, split a database, move static assets to a CDN, run multiple services, or tune the web server for your workload. The path is not always one click, but it is usually under your control.</p>
<p>Dedicated IP addressing is another practical difference. A VPS often includes its own IPv4 address and IPv6 allocation. That can help with mail reputation separation, firewall allowlists, reverse proxy rules, custom SSL/network setups, and avoiding unrelated customers on the same address. It is not an automatic SEO boost. Search engines care more about crawlability, content, performance, and abuse signals than whether you bought a dedicated IP. Still, an address you control can make operations cleaner.</p>
<p>The limits are just as important.</p>
<p>With a VPS, security and operations become your job unless you pay for managed service. Someone has to patch the OS, lock down SSH, configure the firewall, rotate keys, monitor disk space, set up backups, renew certificates, tune the database, and recover from mistakes. If the server dies at 3am, the provider can usually tell you whether the VPS is powered on. It may not debug your Nginx config.</p>
<p>The “not optimally configured” point cuts both ways. An expert can tune a VPS better than a shared host’s generic environment. A novice can make a VPS slower, less secure, and less reliable than decent shared hosting. VPS value depends on the operator’s skill.</p>
<h2 id="vps-vs-shared-hosting-comparison">VPS vs shared hosting comparison<a href="#vps-vs-shared-hosting-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>


















































<table><thead><tr><th>Category</th><th>Shared hosting</th><th>VPS</th></tr></thead><tbody><tr><td>Control</td><td>Low to moderate. The provider controls most of the stack.</td><td>High. You control the operating system and services.</td></tr><tr><td>Performance predictability</td><td>Good enough for small sites, but hidden caps and neighbors can matter.</td><td>More predictable plan resources, with direct system metrics.</td></tr><tr><td>Software you can run</td><td>Usually PHP, static files, MySQL, and approved panel features.</td><td>Almost anything the OS can run, including Docker, queues, APIs, and custom daemons.</td></tr><tr><td>Scalability</td><td>Easy inside the provider’s plan tiers, weaker when the app needs custom architecture.</td><td>Better vertical scaling and more paths to split services later.</td></tr><tr><td>Security responsibility</td><td>Provider handles the server platform. You still handle app passwords, CMS updates, and code.</td><td>You handle OS, firewall, SSH, packages, services, and app security.</td></tr><tr><td>Ops time</td><td>Low. Most server work is hidden.</td><td>Medium to high, depending on automation and experience.</td></tr><tr><td>Cost structure</td><td>You pay for convenience and a managed environment.</td><td>You pay for resources and control, plus your own time.</td></tr><tr><td>Who it is for</td><td>Simple sites, small CMS projects, nontechnical owners, low-ops teams.</td><td>Developers, growing sites, custom apps, background services, teams needing control.</td></tr></tbody></table>
<h2 id="the-cost-myth">The cost myth<a href="#the-cost-myth" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Shared hosting is not always cheaper.</p>
<p>At the very low end, shared hosting wins on price. A small static site or basic WordPress install does not need a dedicated server environment, and paying more for control you will not use is wasteful.</p>
<p>The math changes when a site needs a little more resource. A better shared plan with higher CPU limits, more processes, staging, backups, malware scanning, and premium support can approach or exceed the monthly price of a modest VPS. At that point, the difference is not simply “cheap versus expensive.” It is “managed convenience versus control.”</p>
<p>Shared hosting bundles operations into the price. A VPS unbundles it. If you can run the server well, the VPS may give you more usable capacity per dollar. If you need someone else to manage everything, shared hosting or managed hosting may be the better value even at a higher sticker price.</p>
<h2 id="the-blurred-middle">The blurred middle<a href="#the-blurred-middle" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The line is not as clean as it used to be. Managed VPS plans, container platforms, PaaS products, and serverless hosting all borrow pieces from both models. They can be the right answer, but they are separate decisions. For this comparison, the useful split is still simple: shared hosting sells convenience; VPS sells control.</p>
<h2 id="how-to-choose">How to choose<a href="#how-to-choose" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Start with operations skill.</p>
<p>If you cannot SSH into a server, read logs, patch packages, and recover from a broken config, shared hosting is probably the safer first choice. That is not an insult. It means your time is better spent on the site, product, or content. If you can handle Linux basics, or you have someone who can, a VPS becomes realistic.</p>
<p>Next, look at the app type.</p>
<p>A plain PHP site, brochure site, small WordPress install, or static site can run happily on shared hosting. A Node.js app, Python API, Go service, Dockerized app, long-running bot, queue worker, custom database setup, or private tool usually points toward a VPS.</p>
<p>Then check traffic and resource behavior.</p>
<p>Light traffic with predictable pages is a shared hosting sweet spot. Traffic spikes, slow admin pages, heavy plugins, import jobs, expensive database queries, or repeated “resource limit reached” messages are signs that the shared environment is becoming the constraint. Before blaming the host, measure the layer that is slow. The guide on <a href="https://sa.net/blog/why-is-my-website-slow/" title="why is my website slow">why is my website slow</a> is a useful companion here because it separates hosting problems from app, payload, DNS, and rendering problems.</p>
<p>Finally, ask whether you need to run things beyond the website.</p>
<p>Email processing, bots, background jobs, API workers, WebSocket services, reverse proxies, VPN endpoints, analytics collectors, cron-heavy imports, and staging environments all push you toward VPS hosting. Shared hosting is strongest when the workload is a website. A VPS is stronger when the workload is a small system.</p>
<h2 id="when-it-is-time-to-move-to-a-vps">When it is time to move to a VPS<a href="#when-it-is-time-to-move-to-a-vps" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Move to a VPS when the shared hosting tradeoff stops helping.</p>
<p>Common upgrade signals are straightforward:</p>
<ul>
<li>you keep hitting CPU, memory, process, inode, or database limits</li>
<li>performance changes even when your own site has not changed</li>
<li>you need shell access for deployment or debugging</li>
<li>your app needs non-PHP software, background services, workers, or containers</li>
<li>you want a dedicated IP address or IPv6 allocation for cleaner network operations</li>
<li>traffic has grown enough that predictable resources matter</li>
<li>you need a staging or internal service beside the public site</li>
<li>the shared host’s panel is now shaping your architecture more than your app needs</li>
</ul>
<p>If the problem is performance, confirm the bottleneck first. The answer to “should I switch to a VPS” is yes only when shared hosting is the layer holding you back, or when your workload no longer fits the shared model.</p>
<p>When you reach that point, getting a VPS is the cleaner path. Riven Cloud is a solid option if you want a KVM VPS with a dedicated IPv4 address, a /64 IPv6 block, NVMe SSD storage, and a choice of Tokyo or Singapore node locations, and you can <a href="https://sa.net/pricing/" title="view VPS plans">view VPS plans</a> when you are ready to move.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>There is no absolute winner in VPS vs shared hosting.</p>
<p>Shared hosting is the better fit when convenience matters more than control. It keeps server work out of the way and gives many small sites exactly what they need. VPS hosting is the better fit when the application needs root access, predictable resources, custom software, background services, or a cleaner scaling path.</p>
<p>Match the hosting model to the stage of the project. A simple site can stay shared for a long time. A growing project usually trends toward a VPS because control starts to matter more than convenience.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="VPS" term="VPS"/>
        <category label="Infrastructure" term="Infrastructure"/>
        <category label="Hosting" term="Hosting"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why Is My Website Slow? Hosting or Something Else?]]></title>
        <id>https://sa.net/blog/why-is-my-website-slow/</id>
        <link href="https://sa.net/blog/why-is-my-website-slow/"/>
        <updated>2026-06-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A practical diagnostic guide for separating hosting bottlenecks from DNS, routing, caching, payload, and front-end problems.]]></summary>
        <content type="html"><![CDATA[<p>When someone asks, “why is my website slow?”, hosting is usually the first suspect. The reaction is understandable: the site feels slow, the server has a provider name on the invoice, and changing providers feels like a clean fix.</p>
<p>It is also a common way to waste a week.</p>
<p>A page load crosses several layers before a visitor can use the page: DNS, connection setup, TLS, server processing, content transfer, caching, and browser rendering. Slowness can live in any one of them. If the page is slow because of a 4 MB image, a blocking JavaScript bundle, or an uncached database query, moving the same site may change nothing.</p>
<p>The useful question is not “is my hosting slow?” The useful question is which layer is slow. Diagnose a slow website by isolating that layer first.</p>
<h2 id="mental-model-the-request-lifecycle">Mental model: the request lifecycle<a href="#mental-model-the-request-lifecycle" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A browser does not load a page in one step. It walks through a chain of work:</p>
<ul>
<li>DNS resolution: the browser asks where the hostname points. Slow authoritative DNS, broken records, or bad TTL choices can delay the start.</li>
<li>TCP and TLS handshake: the browser opens a connection and negotiates encryption. Distance, routing, packet loss, old protocol support, and certificate setup can all show up here.</li>
<li>Server processing: the web server, application runtime, database, and upstream APIs produce the response. This is where slow TTFB often becomes visible.</li>
<li>Content download: HTML, images, CSS, JavaScript, fonts, and API responses move over the network. Large payloads and missing compression hurt here.</li>
<li>Browser rendering: the browser parses, styles, lays out, paints, and runs JavaScript. A fast server cannot save a front end that blocks the main thread.</li>
</ul>
<blockquote>
<p>Diagram placeholder: request lifecycle from browser to DNS resolver, TCP/TLS, web server, application/database, content download, and browser rendering.</p>
</blockquote>
<h2 id="why-is-my-website-slow-measure-before-guessing">Why is my website slow? Measure before guessing<a href="#why-is-my-website-slow-measure-before-guessing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Start with a waterfall, not a score. Chrome DevTools documents the Network panel and its request timing view in its <a href="https://developer.chrome.com/docs/devtools/network/reference/" title="Network reference" target="_blank" rel="noopener noreferrer">Network reference</a>. Use it to separate waiting time, download time, and blocked rendering resources.</p>
<p><a href="https://pagespeed.web.dev/" title="PageSpeed Insights" target="_blank" rel="noopener noreferrer">PageSpeed Insights</a> and WebPageTest are useful, but read the waterfall before debating the score. The number at the top is a summary. The waterfall is the evidence.</p>
<blockquote>
<p>Screenshot placeholder: DevTools Network waterfall showing DNS, initial connection, TLS, waiting for server response, content download, and render-blocking CSS or JavaScript.</p>
</blockquote>
<p>Use the command line as a second view. <code>curl</code> exposes timing variables such as <code>time_namelookup</code>, <code>time_connect</code>, <code>time_appconnect</code>, <code>time_starttransfer</code>, and <code>time_total</code> in its <a href="https://curl.se/docs/manpage.html" title="write-out documentation" target="_blank" rel="noopener noreferrer">write-out documentation</a>.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;dns=%{time_namelookup}s tcp=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s bytes=%{size_download} http=%{http_code} proto=%{http_version}\n&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span></code></pre>
<p>Those times are cumulative from the start of the transfer. To estimate stage cost, subtract the previous stage. For example, TLS cost is roughly <code>time_appconnect - time_connect</code>, and server wait plus request time is roughly <code>time_starttransfer - time_appconnect</code> for HTTPS.</p>
<p>Then run the same test against a tiny static file and a dynamic page:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;ttfb=%{time_starttransfer}s total=%{time_total}s bytes=%{size_download}\n&#39;</span><span style="color:#9ECBFF"> https://example.com/favicon.ico</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;ttfb=%{time_starttransfer}s total=%{time_total}s bytes=%{size_download}\n&#39;</span><span style="color:#9ECBFF"> &#39;https://example.com/?probe=1&#39;</span></span></code></pre>
<p>That comparison is the fastest split in the whole investigation. If the static file has low TTFB but the dynamic page has high TTFB, your host may be fine and the app is probably slow. If both are slow from several networks, look harder at the host, origin network, CDN, or routing.</p>
<p>Add basic path checks:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">ping</span><span style="color:#79B8FF"> -c</span><span style="color:#79B8FF"> 20</span><span style="color:#9ECBFF"> example.com</span></span>
<span class="line"><span style="color:#B392F0">traceroute</span><span style="color:#9ECBFF"> example.com</span></span>
<span class="line"><span style="color:#B392F0">mtr</span><span style="color:#79B8FF"> -rwzc</span><span style="color:#79B8FF"> 100</span><span style="color:#9ECBFF"> example.com</span></span></code></pre>
<p>Use MTR from the visitor region when possible. A test from your laptop only proves the path from your laptop.</p>
<h2 id="layer-by-layer-diagnosis">Layer-by-layer diagnosis<a href="#layer-by-layer-diagnosis" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="dns-resolution">DNS resolution<a href="#dns-resolution" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> The page pauses before any connection starts. DevTools shows a long DNS phase, or <code>curl</code> reports high <code>time_namelookup</code>.</p>
<p><strong>How to test:</strong> Check authoritative records and resolver behavior:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">dig</span><span style="color:#9ECBFF"> example.com</span><span style="color:#9ECBFF"> A</span><span style="color:#9ECBFF"> +stats</span></span>
<span class="line"><span style="color:#B392F0">dig</span><span style="color:#9ECBFF"> example.com</span><span style="color:#9ECBFF"> AAAA</span><span style="color:#9ECBFF"> +stats</span></span>
<span class="line"><span style="color:#B392F0">dig</span><span style="color:#9ECBFF"> NS</span><span style="color:#9ECBFF"> example.com</span><span style="color:#9ECBFF"> +short</span></span>
<span class="line"><span style="color:#B392F0">dig</span><span style="color:#9ECBFF"> SOA</span><span style="color:#9ECBFF"> example.com</span><span style="color:#9ECBFF"> +short</span></span>
<span class="line"><span style="color:#B392F0">dig</span><span style="color:#9ECBFF"> +trace</span><span style="color:#9ECBFF"> example.com</span></span></code></pre>
<p>If one resolver is slow but another is normal, the problem may sit with a resolver path rather than your authoritative DNS. If authoritative nameservers are slow, misconfigured, or inconsistent, fix the DNS provider or registrar setup first.</p>
<p><strong>Verdict:</strong> Usually not the host. It is often the registrar, DNS provider, nameserver configuration, or resolver path. The host is involved only when it also runs your authoritative DNS.</p>
<h3 id="connection-and-tls">Connection and TLS<a href="#connection-and-tls" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> DNS finishes quickly, but the connection or TLS phase is long. Visitors far from the server complain more than visitors near the server.</p>
<p><strong>How to test:</strong> Compare TCP, TLS, and protocol support:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;tcp=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s proto=%{http_version}\n&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -I</span><span style="color:#79B8FF"> --http2</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -I</span><span style="color:#79B8FF"> --http3-only</span><span style="color:#9ECBFF"> https://example.com/</span></span></code></pre>
<p>The last command requires a curl build with HTTP/3 support. If it fails locally, use browser DevTools or an external test. MDN’s <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Evolution_of_HTTP" title="HTTP evolution guide" target="_blank" rel="noopener noreferrer">HTTP evolution guide</a> summarizes how HTTP/2 multiplexing and HTTP/3 over QUIC change connection behavior.</p>
<p>TLS overhead is rarely the whole problem on a warm connection, but repeated cold connections from distant regions add up. Certificate errors, missing session reuse, no HTTP/2, no HTTP/3, and no edge cache can make this layer worse than it should. If you are changing certificate configuration, verify the certificate chain, renewal path, redirects, and protocol support before blaming the host.</p>
<p><strong>Verdict:</strong> Mixed. Geographic distance is a node location problem. Routing quality and packet loss can be host-related. Certificate configuration and protocol support may be your web server or CDN configuration.</p>
<h3 id="server-response-and-ttfb">Server response and TTFB<a href="#server-response-and-ttfb" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> DNS, TCP, and TLS look reasonable, but <code>time_starttransfer</code> is high. DevTools shows a long “waiting for server response” phase. Users see a blank page before the first byte arrives.</p>
<p><strong>How to test:</strong> Compare static TTFB with dynamic TTFB from the same client:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &#39;Cache-Control: no-cache&#39;</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;static_ttfb=%{time_starttransfer}s total=%{time_total}s\n&#39;</span><span style="color:#9ECBFF"> https://example.com/favicon.ico</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &#39;Cache-Control: no-cache&#39;</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;dynamic_ttfb=%{time_starttransfer}s total=%{time_total}s\n&#39;</span><span style="color:#9ECBFF"> &#39;https://example.com/?uncached=1&#39;</span></span></code></pre>
<p>If both are slow, check the server itself:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">uptime</span></span>
<span class="line"><span style="color:#B392F0">free</span><span style="color:#79B8FF"> -m</span></span>
<span class="line"><span style="color:#B392F0">vmstat</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> 10</span></span>
<span class="line"><span style="color:#B392F0">top</span><span style="color:#79B8FF"> -bn1</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> sed</span><span style="color:#79B8FF"> -n</span><span style="color:#9ECBFF"> &#39;1,5p&#39;</span></span></code></pre>
<p>On a VPS, sustained CPU steal while the instance is lightly loaded is a red flag. It means the hypervisor is not giving your virtual CPU the time it asked for. RAM pressure, swap churn, and disk wait can create the same user-visible symptom.</p>
<p>If the static file is fast and the dynamic page is slow, move up the stack. Check application logs, PHP-FPM status, slow database queries, cache misses, upstream API latency, and queue workers. In a WordPress, PHP, or custom app, one uncached page or slow SQL query can make the host look guilty.</p>
<p><strong>Verdict:</strong> Could be host or app. High TTFB on a tiny static file points toward host, origin network, CDN miss behavior, or web server configuration. High TTFB only on dynamic pages usually points to application code, database work, PHP/runtime limits, or cache misses.</p>
<h3 id="payload-size">Payload size<a href="#payload-size" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> TTFB is fine, but total load time is high. The waterfall shows large images, fonts, JavaScript bundles, CSS, or API responses taking most of the time.</p>
<p><strong>How to test:</strong> Look at transferred bytes in DevTools and compare compressed responses:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -D</span><span style="color:#9ECBFF"> -</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> &#39;Accept-Encoding: br,gzip&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;bytes=%{size_download} total=%{time_total}s\n&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span></code></pre>
<p>Check for <code>content-encoding: br</code> or <code>content-encoding: gzip</code> on compressible text responses. MDN’s <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Compression" title="HTTP compression guide" target="_blank" rel="noopener noreferrer">HTTP compression guide</a> explains <code>Accept-Encoding</code> and response compression. For images, inspect dimensions and formats. A 3000 px image squeezed into a 360 px mobile slot is a content problem, not a VPS problem.</p>
<p><strong>Verdict:</strong> Not the host. Hosting can affect throughput, but unoptimized images, excessive JavaScript, unused CSS, and missing compression belong to the site build and delivery configuration.</p>
<h3 id="caching-and-cdn">Caching and CDN<a href="#caching-and-cdn" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> The first request is slow, repeat requests are faster, or every page hit regenerates dynamic content. Global visitors see inconsistent results.</p>
<p><strong>How to test:</strong> Inspect cache headers and repeat requests:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -I</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -I</span><span style="color:#9ECBFF"> https://example.com/assets/app.css</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;ttfb=%{time_starttransfer}s total=%{time_total}s\n&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> /dev/null</span><span style="color:#79B8FF"> -sS</span><span style="color:#79B8FF"> -w</span><span style="color:#9ECBFF"> &#39;ttfb=%{time_starttransfer}s total=%{time_total}s\n&#39;</span><span style="color:#9ECBFF"> https://example.com/</span></span></code></pre>
<p>Look for <code>cache-control</code>, <code>etag</code>, <code>last-modified</code>, <code>age</code>, <code>vary</code>, and CDN-specific headers such as <code>x-cache</code>. A page cache can turn dynamic generation into a quick static response. A CDN can move static assets closer to users, but cache-busting query strings, <code>no-store</code> headers, and logged-in cookies can bypass it.</p>
<p><strong>Verdict:</strong> Host-adjacent and configuration-driven. The host may provide a cache or CDN integration, but the policy is usually yours: what can be cached, for how long, and whether dynamic pages are safe to cache.</p>
<h3 id="front-end-rendering">Front-end rendering<a href="#front-end-rendering" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> HTML arrives quickly, but the page still feels slow. DevTools shows long main-thread tasks, render-blocking CSS, large JavaScript execution time, layout shifts, or late-loading fonts.</p>
<p><strong>How to test:</strong> In DevTools, combine the Network panel with the Performance panel. Look for blocking scripts in the waterfall and long tasks during parse, style, layout, and paint. PageSpeed Insights can flag render-blocking resources and main-thread work, but confirm the finding in the trace rather than treating the score as the diagnosis.</p>
<p>Common fixes include deferring noncritical JavaScript, splitting bundles, removing unused CSS, preloading the right font files, setting image dimensions, and moving expensive work out of the critical path.</p>
<p><strong>Verdict:</strong> Not the host. A stronger CPU on the origin server will not reduce the JavaScript execution cost on a visitor’s phone.</p>
<h3 id="network-path-routing-and-peering">Network path, routing, and peering<a href="#network-path-routing-and-peering" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p><strong>Symptom:</strong> The site is fast for you and slow for a specific country, ISP, office, or mobile carrier. TTFB may be acceptable near the data center and poor near the audience. Packet loss or route detours appear in MTR.</p>
<p><strong>How to test:</strong> Run path tests from the visitor geography, not only from your workstation:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">mtr</span><span style="color:#79B8FF"> -rwzc</span><span style="color:#79B8FF"> 100</span><span style="color:#9ECBFF"> example.com</span></span>
<span class="line"><span style="color:#B392F0">traceroute</span><span style="color:#9ECBFF"> example.com</span></span>
<span class="line"><span style="color:#B392F0">ping</span><span style="color:#79B8FF"> -c</span><span style="color:#79B8FF"> 50</span><span style="color:#9ECBFF"> example.com</span></span></code></pre>
<p>Ask users in the affected region to run the same tests, or use a Looking Glass near them. Compare routes by ASN and city, not just hop count. A physically close server can still take a poor carrier path. A farther server with better peering can sometimes behave better for the audience you care about. For planning, match the node location to the visitor geography you actually need to serve.</p>
<p><strong>Verdict:</strong> Host-related. Node location, carrier mix, transit quality, peering, and return path all belong to the hosting network decision. Your application code cannot fix a bad route to your main audience.</p>
<h2 id="so-is-it-actually-the-host">So is it actually the host?<a href="#so-is-it-actually-the-host" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Hosting is the proven bottleneck when the evidence points below the application layer.</p>
<p>Strong host signals include high TTFB on a tiny static file from multiple test networks, sustained CPU steal or disk wait on a lightly loaded VPS, RAM pressure that you did not create, packet loss on the path to the origin, or poor routing to your main visitor geography. Another common signal is the regional split: “fine for me, slow for visitors elsewhere.” That often means the node is in the wrong region or the network path is poor for that audience.</p>
<p>Weak host signals include a low PageSpeed score with huge images, slow dynamic pages while static files are fast, long JavaScript tasks after HTML arrives, or missing cache headers. Those problems can follow you to any provider.</p>













































<table><thead><tr><th>Signal</th><th>Likely host issue?</th><th>Why</th></tr></thead><tbody><tr><td>Tiny static file has high TTFB from several regions</td><td>Yes</td><td>Origin server, node network, CDN miss path, or overloaded VPS may be slow</td></tr><tr><td>Static file is fast but dynamic page TTFB is high</td><td>Usually no</td><td>Application, database, PHP/runtime, or upstream API work is slow</td></tr><tr><td>DNS lookup is slow or inconsistent</td><td>Usually no</td><td>Registrar, authoritative DNS provider, resolver path, or bad records are more likely</td></tr><tr><td>Large images or JavaScript dominate the waterfall</td><td>No</td><td>Payload and front-end build problems follow the site to any host</td></tr><tr><td>Repeat view is much faster than first view</td><td>Mixed</td><td>Caching policy, CDN, and application cache need review</td></tr><tr><td>Visitors in one region are slow while nearby users are fine</td><td>Often yes</td><td>Node location, peering, routing, and return path matter</td></tr><tr><td>CPU steal, disk wait, or swap churn appear under light load</td><td>Yes</td><td>Oversold VPS, noisy neighbor, or under-provisioned instance is plausible</td></tr></tbody></table>
<h2 id="decision-flowchart">Decision flowchart<a href="#decision-flowchart" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Slow before connection starts</span></span>
<span class="line"><span>  -&gt; likely layer: DNS</span></span>
<span class="line"><span>  -&gt; fix: authoritative DNS, registrar records, resolver path, TTL policy</span></span>
<span class="line"><span></span></span>
<span class="line"><span>High TCP or TLS time</span></span>
<span class="line"><span>  -&gt; likely layer: distance, routing, protocol support, TLS config</span></span>
<span class="line"><span>  -&gt; fix: closer node, better route, HTTP/2 or HTTP/3, clean certificate setup</span></span>
<span class="line"><span></span></span>
<span class="line"><span>High TTFB on static and dynamic URLs</span></span>
<span class="line"><span>  -&gt; likely layer: host, origin web server, CDN miss path, routing</span></span>
<span class="line"><span>  -&gt; fix: inspect VPS load, CPU steal, disk wait, packet loss, node location</span></span>
<span class="line"><span></span></span>
<span class="line"><span>High TTFB only on dynamic URLs</span></span>
<span class="line"><span>  -&gt; likely layer: application or database</span></span>
<span class="line"><span>  -&gt; fix: page cache, query profiling, PHP/runtime tuning, upstream timeout review</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Low TTFB but high total load time</span></span>
<span class="line"><span>  -&gt; likely layer: payload</span></span>
<span class="line"><span>  -&gt; fix: compress text, resize images, reduce JS and CSS, cache assets</span></span>
<span class="line"><span></span></span>
<span class="line"><span>HTML arrives quickly but the page is still unusable</span></span>
<span class="line"><span>  -&gt; likely layer: front-end rendering</span></span>
<span class="line"><span>  -&gt; fix: remove render blockers, reduce main-thread work, defer noncritical scripts</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Slow only for one geography or carrier</span></span>
<span class="line"><span>  -&gt; likely layer: routing and peering</span></span>
<span class="line"><span>  -&gt; fix: choose a better node, test Looking Glass paths, use CDN or provider with better regional routing</span></span></code></pre>
<h2 id="when-changing-or-upgrading-hosting-genuinely-helps">When changing or upgrading hosting genuinely helps<a href="#when-changing-or-upgrading-hosting-genuinely-helps" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Changing providers helps when the host is the measured bottleneck: an oversold VPS with CPU steal, an instance without enough RAM or disk performance, a node far from your users, weak peering to the target region, no IPv6 where your audience benefits from it, no HTTP/3 or modern edge support in your delivery path, or an IP setup that does not fit the service.</p>
<p>If your measurements point to location or routing as the bottleneck, compare Riven Cloud’s <a href="https://sa.net/locations/" title="Tokyo and Singapore locations">Tokyo and Singapore locations</a> and <a href="https://sa.net/network/" title="network paths">network paths</a>. When those match your audience, you can <a href="https://sa.net/pricing/" title="view VPS plans">view VPS plans</a> or order from the <a href="https://portal.sa.net/store/cloud-server/" title="Riven Cloud portal" target="_blank" rel="noopener noreferrer">Riven Cloud portal</a>.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The next time the question is “why is my website slow?”, do not start with a provider migration. Start with the waterfall, split static from dynamic, check the path, and name the slow layer.</p>
<p>Switching hosts helps when hosting is the proven bottleneck. If DNS is broken, fix DNS. If the app is generating pages slowly, fix the app or cache it. If the payload is too large, shrink it. If the route to your users is bad, then hosting location and network quality become the right problem to solve.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Network" term="Network"/>
        <category label="VPS" term="VPS"/>
        <category label="Infrastructure" term="Infrastructure"/>
        <category label="Performance" term="Performance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Is CMIN2? China Mobile International N2 and AS58807 Explained]]></title>
        <id>https://sa.net/blog/china-mobile-premium/</id>
        <link href="https://sa.net/blog/china-mobile-premium/"/>
        <updated>2026-06-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Learn what CMIN2 means, how China Mobile International N2 and AS58807 work, and how Riven Cloud routes Tokyo VPS traffic for China Mobile users.]]></summary>
        <content type="html"><![CDATA[<p>China Mobile is often the least understood part of China-optimized VPS routing.</p>
<p>Many buyers already know what to look for on China Telecom: CN2 GIA, CTGNet, AS4809, AS23764. They may also know China Unicom Premium through AS9929 and AS10099. China Mobile has its own premium path, and the signal to look for is CMIN2 / AS58807.</p>
<p>For China Mobile users, the route question is simple:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Does traffic move from China Mobile AS9808 into CMIN2 / AS58807,</span></span>
<span class="line"><span>or does it fall back to ordinary CMI / AS58453 and standard international transit?</span></span></code></pre>
<p>For Riven Cloud Tokyo, the optimized China Mobile path in the June 29, 2026 sample was:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile access / regional network</span></span>
<span class="line"><span>-&gt; China Mobile domestic backbone / AS9808</span></span>
<span class="line"><span>-&gt; CMIN2 / China Mobile International N2 / AS58807</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The ordinary SoftBank transit baseline from the same comparison set looked different:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile access / regional network</span></span>
<span class="line"><span>-&gt; China Mobile AS9808</span></span>
<span class="line"><span>-&gt; ordinary CMI / AS58453</span></span>
<span class="line"><span>-&gt; SoftBank AS17676</span></span>
<span class="line"><span>-&gt; AS3258 Tokyo endpoint</span></span></code></pre>
<p>That difference matters. A VPS can be in Japan and still take the wrong carrier path for China Mobile users.</p>
<h2 id="the-china-mobile-networks-customers-should-know">The China Mobile networks customers should know<a href="#the-china-mobile-networks-customers-should-know" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Mobile routing has several layers. For VPS buyers, four ASNs are worth knowing.</p>
<p><a href="https://bgp.he.net/AS9808" title="Hurricane Electric’s BGP Toolkit lists AS9808 as China Mobile Communications Group Co., Ltd." target="_blank" rel="noopener noreferrer">Hurricane Electric’s BGP Toolkit lists AS9808 as China Mobile Communications Group Co., Ltd.</a>. That is the domestic China Mobile backbone side. A user may start from a provincial or regional China Mobile network first, then enter AS9808.</p>
<p><a href="https://www.peeringdb.com/asn/58807" title="PeeringDB lists AS58807 as China Mobile International - NII" target="_blank" rel="noopener noreferrer">PeeringDB lists AS58807 as China Mobile International - NII</a>, with <code>CMIN2</code> as its also-known-as value. The same PeeringDB record shows AS58807 at Tokyo, Singapore, Hong Kong, Frankfurt, and other interconnection points. <a href="https://bgp.tools/as/58807" title="BGP.tools also lists AS58807 as China Mobile International Limited" target="_blank" rel="noopener noreferrer">BGP.tools also lists AS58807 as China Mobile International Limited</a>, with prefix descriptions such as Japan N2 Network, Singapore N2 Network, and Germany N2 Network.</p>
<p><a href="https://bgp.he.net/AS58453" title="Hurricane Electric’s BGP Toolkit lists AS58453 as China Mobile International Limited" target="_blank" rel="noopener noreferrer">Hurricane Electric’s BGP Toolkit lists AS58453 as China Mobile International Limited</a>. In the baseline route below, AS58453 appears as the ordinary CMI international side before SoftBank AS17676. <a href="https://www.peeringdb.com/asn/17676" title="PeeringDB lists AS17676 as SoftBank Corp." target="_blank" rel="noopener noreferrer">PeeringDB lists AS17676 as SoftBank Corp.</a>, and <a href="https://www.peeringdb.com/asn/3258" title="AS3258 as xTom Tokyo" target="_blank" rel="noopener noreferrer">AS3258 as xTom Tokyo</a>.</p>
<p>Use this practical map:</p>
<ul>
<li>China Mobile regional or access networks: where many broadband, enterprise, or mobile users begin.</li>
<li><code>AS9808</code>: China Mobile’s domestic backbone.</li>
<li><code>AS58807</code>: CMIN2 / China Mobile International N2, the premium international path.</li>
<li><code>AS58453</code>: ordinary China Mobile International / CMI, commonly seen on standard China Mobile international routes.</li>
<li><code>AS17676</code>: SoftBank, used in the standard transit baseline.</li>
<li><code>AS3258</code>: the Tokyo network used by the test endpoints.</li>
</ul>
<p>AS9808 is the domestic side. AS58807 is the premium CMIN2 international side. A strong China Mobile optimized route should show traffic moving from AS9808 into AS58807 before it reaches the VPS provider.</p>
<h2 id="what-cmin2-means">What CMIN2 means<a href="#what-cmin2-means" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CMIN2 means China Mobile International N2. In the VPS market, it is the premium China Mobile path customers should look for when evaluating China Mobile optimized connectivity.</p>
<p>It plays a similar customer-facing role for China Mobile users as CN2 GIA does for China Telecom users or CUP / AS9929 does for China Unicom users. It is not the same network and should not be described as the same product. The useful comparison is functional: it gives China Mobile traffic a carrier-matched premium path instead of pushing it through ordinary international transit.</p>
<p>For Riven Cloud, the shorthand is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile traffic from mainland China</span></span>
<span class="line"><span>-&gt; AS9808 domestically</span></span>
<span class="line"><span>-&gt; CMIN2 / AS58807 internationally</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The old habit of checking only Telecom and Unicom is incomplete. If your users are on China Mobile broadband or China Mobile 5G, AS58807 is the route signal to check.</p>
<h2 id="cmin2--as58807-and-ordinary-cmi--as58453-are-not-the-same-route">CMIN2 / AS58807 and ordinary CMI / AS58453 are not the same route<a href="#cmin2--as58807-and-ordinary-cmi--as58453-are-not-the-same-route" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CMIN2 and ordinary CMI are related to China Mobile International, but they should not be treated as interchangeable.</p>
<p>AS58807 is the CMIN2 / N2 premium path. It is the AS Riven Cloud peers with for China Mobile optimized routing, and it is the desired path for China Mobile users accessing the Riven Cloud Tokyo optimized node.</p>
<p>AS58453 is the ordinary CMI path seen in the baseline route. It can be useful for general Internet traffic, but in this China Mobile to Tokyo sample it then handed traffic to SoftBank AS17676 before reaching AS3258.</p>
<p>That is the operational split:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Optimized:</span></span>
<span class="line"><span>AS9808 -&gt; CMIN2 / AS58807 -&gt; AS3258</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Standard transit baseline:</span></span>
<span class="line"><span>AS9808 -&gt; ordinary CMI / AS58453 -&gt; SoftBank AS17676 -&gt; AS3258</span></span></code></pre>
<p>The names sound close enough to confuse people. The routes are visibly different.</p>
<h2 id="the-riven-cloud-china-mobile-cmin2-path">The Riven Cloud China Mobile CMIN2 path<a href="#the-riven-cloud-china-mobile-cmin2-path" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized path for China Mobile users is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile user</span></span>
<span class="line"><span>-&gt; China Mobile regional/access network</span></span>
<span class="line"><span>-&gt; China Mobile domestic backbone / AS9808</span></span>
<span class="line"><span>-&gt; CMIN2 / China Mobile International N2 / AS58807</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>Each part has a job:</p>
<ul>
<li>The user may start from a regional China Mobile access network, such as a provincial CMNET segment.</li>
<li>AS9808 is the China Mobile domestic backbone.</li>
<li>AS58807 is CMIN2 / China Mobile International N2.</li>
<li>AS3258 is the Tokyo network used by the Riven Cloud optimized test node.</li>
</ul>
<p>Riven Cloud peers with China Mobile through CMIN2 / AS58807. China Mobile traffic from mainland China uses AS9808 domestically before reaching CMIN2 / AS58807, then Riven Cloud.</p>
<h2 id="optimized-route-mtr-sample">Optimized route MTR sample<a href="#optimized-route-mtr-sample" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized mainland China to Tokyo sample came from a third-party MTR-style capture with 20 probes. The return MTR from Tokyo to mainland China started at <code>2026-06-29T10:34:03+0000</code> and used 10 probes.</p>
<p>The forward sample showed a China Mobile regional access side, AS9808, CMIN2 / AS58807, and the final Riven Cloud endpoint on AS3258. The return sample showed AS3258, then AS58807, then AS9808, then the China Mobile access endpoint.</p>


























<table><thead><tr><th>Direction</th><th>Visible premium path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>China Mobile access -&gt; AS9808 -&gt; CMIN2/AS58807 -&gt; AS3258</td><td style="text-align:right">40.0 ms</td><td style="text-align:right">39.8 ms</td><td style="text-align:right">0.0%</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; CMIN2/AS58807 -&gt; AS9808 -&gt; China Mobile access</td><td style="text-align:right">44.7 ms</td><td style="text-align:right">44.6 ms</td><td style="text-align:right">0.0%</td></tr></tbody></table>
<p>The optimized route shows the expected China Mobile premium structure in both directions. Traffic moves between AS9808 and CMIN2 / AS58807, instead of using ordinary CMI and standard transit.</p>
<h2 id="standard-softbank-transit-comparison">Standard SoftBank transit comparison<a href="#standard-softbank-transit-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The same June 29, 2026 comparison set included a standard SoftBank transit baseline to an AS3258 Tokyo endpoint. It was not the same destination IP as the Riven Cloud optimized node, so treat it as a route-pattern baseline rather than a same-host A/B test.</p>
<p>The baseline path was:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile user</span></span>
<span class="line"><span>-&gt; China Mobile regional/access network</span></span>
<span class="line"><span>-&gt; China Mobile AS9808</span></span>
<span class="line"><span>-&gt; ordinary CMI / AS58453</span></span>
<span class="line"><span>-&gt; SoftBank AS17676</span></span>
<span class="line"><span>-&gt; AS3258 Tokyo endpoint</span></span></code></pre>
<p>Cleaned final-hop results from that baseline were:</p>


























<table><thead><tr><th>Direction</th><th>Standard transit path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>AS9808 -&gt; ordinary CMI/AS58453 -&gt; SoftBank/AS17676 -&gt; AS3258</td><td style="text-align:right">72.2 ms</td><td style="text-align:right">72.0 ms</td><td style="text-align:right">0.0%</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; SoftBank/AS17676 -&gt; ordinary CMI/AS58453 -&gt; AS9808</td><td style="text-align:right">70.0 ms</td><td style="text-align:right">69.9 ms</td><td style="text-align:right">0.0%</td></tr></tbody></table>
<p>This does not mean SoftBank or ordinary CMI is bad. They can be fine for many general routes. For this China Mobile to Tokyo sample, the standard transit path had much higher average RTT than the CMIN2 optimized path.</p>
<h2 id="side-by-side-results">Side-by-side results<a href="#side-by-side-results" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The CMIN2 optimized path reduced average RTT in both directions.</p>


























<table><thead><tr><th>Direction</th><th style="text-align:right">Standard transit avg RTT</th><th style="text-align:right">CMIN2 optimized avg RTT</th><th style="text-align:right">Reduction</th><th>Optimized path</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td style="text-align:right">72.2 ms</td><td style="text-align:right">40.0 ms</td><td style="text-align:right">32.2 ms lower / about 45% lower</td><td>AS9808 -&gt; CMIN2/AS58807</td></tr><tr><td>Tokyo to mainland China</td><td style="text-align:right">70.0 ms</td><td style="text-align:right">44.7 ms</td><td style="text-align:right">25.3 ms lower / about 36% lower</td><td>CMIN2/AS58807 -&gt; AS9808</td></tr></tbody></table>
<p>The latency reduction is useful. The path evidence is the bigger point: the optimized route used AS58807, the intended China Mobile premium path, instead of ordinary CMI / AS58453 and standard international transit.</p>
<p>For China Mobile customers, this is the missing piece in many route comparisons. Telecom may have CN2/CTGNet. Unicom may have AS9929/AS10099. Mobile needs its own premium path too.</p>
<h2 id="why-cmin2-matters-for-china-mobile-users">Why CMIN2 matters for China Mobile users<a href="#why-cmin2-matters-for-china-mobile-users" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Mobile users were often the weak spot in older “China optimized” VPS offers. A provider might optimize for China Telecom and China Unicom, then leave China Mobile to ordinary international routing.</p>
<p>CMIN2 changes the checklist. China Mobile users can now look for a premium path of their own: AS9808 on the domestic side, AS58807 on the international side, then the provider network.</p>
<p>In practical terms, that can mean:</p>
<ul>
<li>Lower latency for China Mobile users.</li>
<li>More responsive SSH sessions.</li>
<li>Faster website loading from China Mobile networks.</li>
<li>Better API response times from mainland China.</li>
<li>Smoother control panel or remote desktop access.</li>
<li>Less dependence on ordinary transit paths.</li>
<li>Better carrier matching for services with China Mobile users.</li>
</ul>
<p>The route does not make every application fast. It removes one common source of delay: sending China Mobile traffic through a less suitable international path.</p>
<h2 id="how-to-check-whether-a-route-is-really-cmin2">How to check whether a route is really CMIN2<a href="#how-to-check-whether-a-route-is-really-cmin2" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A CMIN2 route should usually show:</p>
<ul>
<li>AS9808 on the mainland China domestic side.</li>
<li>AS58807 on the China Mobile International N2 side.</li>
<li>The provider AS after AS58807.</li>
</ul>
<p>For Riven Cloud Tokyo, the expected optimized pattern is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China to Tokyo:</span></span>
<span class="line"><span>China Mobile access -&gt; AS9808 -&gt; AS58807 -&gt; AS3258</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Tokyo to China:</span></span>
<span class="line"><span>AS3258 -&gt; AS58807 -&gt; AS9808 -&gt; China Mobile access</span></span></code></pre>
<p>The ordinary baseline pattern may look like this:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile access -&gt; AS9808 -&gt; AS58453 -&gt; AS17676 -&gt; AS3258</span></span></code></pre>
<p>Router IPs and city codes can change. The AS-level pattern is the part to check first.</p>
<h2 id="how-to-read-mtr-without-chasing-noise">How to read MTR without chasing noise<a href="#how-to-read-mtr-without-chasing-noise" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>MTR is useful, but it is not a packet-loss oracle.</p>
<p>Intermediate routers may rate-limit ICMP, ignore probes, or show strange control-plane latency while still forwarding customer traffic normally. A 100% non-response on an intermediate hop does not prove end-to-end packet loss when later hops and the final destination continue to respond.</p>
<p>The optimized return sample is a good example. Several early AS3258 intermediate hops reported high latency or loss-like behavior, but later AS58807, AS9808, and final access-network hops were normal. The final destination showed 44.7 ms average RTT, 44.6 ms best RTT, and 0.0% packet loss.</p>
<p>When checking CMIN2 routes, focus on:</p>
<ul>
<li>Final-hop average RTT.</li>
<li>Final-hop packet loss.</li>
<li>Whether the path shows AS9808 and AS58807.</li>
<li>Whether the return direction also uses AS58807 and AS9808.</li>
<li>Whether intermediate loss continues to the final destination.</li>
</ul>
<p>Short MTR samples are useful route evidence. They are not an SLA for every province, every hour, or every future routing change.</p>
<h2 id="what-cmin2-cannot-guarantee">What CMIN2 cannot guarantee<a href="#what-cmin2-cannot-guarantee" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CMIN2 gives China Mobile traffic a better path. It does not make every last-mile network perfect.</p>
<p>Performance can still depend on province, local China Mobile access quality, home broadband or 5G conditions, time of day, carrier routing changes, international cable incidents, firewall behavior, application protocol, customer-side Wi-Fi, and router quality.</p>
<p>CMIN2 also cannot make all mobile networks behave like fiber broadband. A phone on a congested cell, a home router with poor Wi-Fi, or an application with bad retry behavior can still feel slow.</p>
<p>Treat the route as one part of the deployment decision, alongside CPU, RAM, NVMe storage, monthly transfer, backup policy, and application design.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For China Mobile users, Riven Cloud’s optimized Tokyo route is designed around the CMIN2 premium path:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Mobile access / regional network</span></span>
<span class="line"><span>-&gt; AS9808 domestic backbone</span></span>
<span class="line"><span>-&gt; CMIN2 / China Mobile International N2 / AS58807</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>In the June 29, 2026 samples, that path delivered 40.0 ms average RTT from mainland China to Tokyo and 44.7 ms average RTT from Tokyo back to mainland China, both with 0.0% final-hop packet loss.</p>
<p>The standard SoftBank transit baseline averaged 72.2 ms from mainland China to Tokyo and 70.0 ms in the return direction. The cleaner result came from the path choice: AS9808 into CMIN2 / AS58807 instead of AS58453 and SoftBank transit.</p>
<p>For the full three-network view, read <a href="https://sa.net/blog/what-are-china-optimized-routes/" title="What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained">What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained</a>. For the other carrier-specific articles, see <a href="https://sa.net/blog/china-telecom-premium/" title="China Telecom premium routing">China Telecom premium routing</a> and <a href="https://sa.net/blog/china-unicom-premium/" title="China Unicom Premium routing">China Unicom Premium routing</a>.</p>
<p>If your users are on China Mobile in mainland China, test the path from the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a> and compare it with your current route. If the CMIN2 path fits your users, <a href="https://sa.net/pricing/" title="view the Riven Cloud VPS plans">view the Riven Cloud VPS plans</a> and deploy a Tokyo server built for China Mobile traffic to take the right carrier path before it reaches your application.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="China Mobile" term="China Mobile"/>
        <category label="CMIN2" term="CMIN2"/>
        <category label="AS58807" term="AS58807"/>
        <category label="AS9808" term="AS9808"/>
        <category label="China-optimized VPS" term="China-optimized VPS"/>
        <category label="Routing" term="Routing"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Is CTGNet / CN2 GIA? China Telecom AS4809 and AS23764 Explained]]></title>
        <id>https://sa.net/blog/china-telecom-premium/</id>
        <link href="https://sa.net/blog/china-telecom-premium/"/>
        <updated>2026-06-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Learn how China Telecom premium routing works, why CTGNet and CN2 GIA are different parts of the path, and how Riven Cloud routes Tokyo VPS traffic for China Telecom users.]]></summary>
        <content type="html"><![CDATA[<p>Many VPS providers advertise “CN2,” “CN2 GIA,” or “China Telecom optimized.” The label is easy to print on a product page. The path is harder to fake.</p>
<p>For China Telecom users, the useful question is whether traffic uses the premium China Telecom route in both directions: the China Telecom access network, the CN2/AS4809 premium segment, the current CTGNet/AS23764 international delivery path, and then the VPS provider network.</p>
<p>For Riven Cloud Tokyo, the optimized China Telecom path is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Telecom access network / AS4134</span></span>
<span class="line"><span>-&gt; CN2 premium segment / AS4809 / 59.43</span></span>
<span class="line"><span>-&gt; CTGNet international edge / AS23764</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>That is materially different from a normal international transit path such as:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Telecom AS4134</span></span>
<span class="line"><span>-&gt; SoftBank AS17676</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The MTR samples in this article were collected on June 29, 2026. They are short samples, not an SLA. They are useful because they show both the visible AS path and the final-hop behavior for the optimized China Telecom route, then compare it with a standard SoftBank transit baseline from the same test set.</p>
<h2 id="china-telecom-routing-has-several-moving-parts">China Telecom routing has several moving parts<a href="#china-telecom-routing-has-several-moving-parts" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Telecom routing is easy to oversimplify because several related names show up in the same conversation.</p>
<p><a href="https://www.chinatelecomasiapacific.com/global-internet-access" title="China Telecom’s Global Internet Access page" target="_blank" rel="noopener noreferrer">China Telecom’s Global Internet Access page</a> says the service offers access to ChinaNet (AS4134) and CN2 (AS4809). Public PeeringDB records list <a href="https://www.peeringdb.com/asn/23764" title="CTGNet as AS23764" target="_blank" rel="noopener noreferrer">CTGNet as AS23764</a> with the IRR as-set <code>AS-CTGNET</code>, and <a href="https://www.peeringdb.com/asn/4809" title="China Telecom / CN2 as AS4809" target="_blank" rel="noopener noreferrer">China Telecom / CN2 as AS4809</a> with the IRR as-set <code>AS-CN2</code>.</p>
<p>For VPS buyers, the practical map looks like this:</p>
<ul>
<li><code>AS4134</code>: ChinaNet / China Telecom’s large public Internet backbone and access network.</li>
<li><code>AS4809</code>: CN2 / China Telecom’s premium backbone segment, commonly associated with CN2 GIA in the VPS market.</li>
<li><code>AS23764</code>: CTGNet / China Telecom Global’s international network used for peering and international delivery.</li>
</ul>
<p>A China Telecom broadband user may begin in AS4134 or a regional access network. A premium route should move the traffic into the CN2/AS4809 segment and then use CTGNet/AS23764 for the international handoff.</p>
<p>That last point is where many route descriptions get sloppy. CTGNet and CN2 GIA are related in how customers experience the route, but they are not interchangeable terms.</p>
<h2 id="what-cn2-gia-means-in-practice">What CN2 GIA means in practice<a href="#what-cn2-gia-means-in-practice" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CN2 GIA is market shorthand for China Telecom’s premium global Internet access route. VPS buyers usually associate it with the CN2/AS4809 backbone, lower latency, less congestion, and more predictable cross-border performance than ordinary ChinaNet routes.</p>
<p>The label alone does not prove much. A useful route check asks two questions:</p>
<ul>
<li>Does the route show the premium CN2 segment, often visible as <code>59.43</code> hops or AS4809?</li>
<li>Does the international handoff avoid ordinary transit and land on the intended China Telecom Global path?</li>
</ul>
<p>In practice, customers should look less at the label and more at the path. A trace that says “CN2” somewhere in the middle is weaker evidence than a clean path showing China Telecom access, CN2/AS4809, CTGNet/AS23764, and the provider network in both directions.</p>
<h2 id="what-ctgnet-means-in-this-route">What CTGNet means in this route<a href="#what-ctgnet-means-in-this-route" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CTGNet is the China Telecom Global international network associated with AS23764. In the Riven Cloud Tokyo route, CTGNet is the peer-facing international side of the China Telecom path.</p>
<p>CN2/AS4809 is the premium China Telecom segment that mainland China users have historically associated with CN2 GIA. CTGNet/AS23764 is the international delivery and interconnection side used in the current route.</p>
<p>Riven Cloud peers with China Telecom through CTGNet / AS23764. China Telecom traffic from mainland China uses the CN2 / AS4809 premium segment before reaching CTGNet / AS23764, where it is handed to Riven Cloud’s Tokyo network on AS3258. PeeringDB lists <a href="https://www.peeringdb.com/asn/3258" title="AS3258 as xTom Tokyo" target="_blank" rel="noopener noreferrer">AS3258 as xTom Tokyo</a>, which matches the Tokyo network context for this service.</p>
<p>The clean shorthand is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>AS4134 access</span></span>
<span class="line"><span>-&gt; CN2 / AS4809 / 59.43</span></span>
<span class="line"><span>-&gt; CTGNet / AS23764</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<h2 id="the-riven-cloud-china-telecom-path">The Riven Cloud China Telecom path<a href="#the-riven-cloud-china-telecom-path" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized path from a China Telecom user to Riven Cloud Tokyo should show the China Telecom access side first, then the premium CN2 segment, then CTGNet, then the Riven Cloud endpoint.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Telecom user</span></span>
<span class="line"><span>-&gt; China Telecom access network / AS4134</span></span>
<span class="line"><span>-&gt; CN2 premium segment / AS4809 / 59.43</span></span>
<span class="line"><span>-&gt; CTGNet international edge / AS23764</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>Each part has a job:</p>
<ul>
<li>AS4134 is where many ordinary China Telecom users begin.</li>
<li>The <code>59.43</code> hops indicate the CN2 premium segment.</li>
<li>CTGNet / AS23764 is the international handoff and China Telecom peer path.</li>
<li>AS3258 is the Tokyo network used by the Riven Cloud test node.</li>
</ul>
<p>The reverse direction matters as much as the forward direction. A route can look good from China to Tokyo and still return through a different, less suitable path. The Riven Cloud sample below showed the expected CTGNet and CN2 path in both directions.</p>
<h2 id="optimized-route-mtr-sample">Optimized route MTR sample<a href="#optimized-route-mtr-sample" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized MTR samples were collected on June 29, 2026 at <code>2026-06-29T10:22:04+0000</code> for mainland China to Tokyo and <code>2026-06-29T10:23:28+0000</code> for Tokyo to mainland China. Each sample used 10 probes.</p>
<p>The raw output showed China Telecom AS4134 near the access side, multiple <code>59.43</code> hops on the CN2 segment, CTGNet hostnames such as <code>ct163.jp.tyo.ctgnet</code> and <code>jp.tyo.ctgnet</code>, and the final Riven Cloud Tokyo endpoint on AS3258.</p>


























<table><thead><tr><th>Direction</th><th>Visible premium path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>AS4134 -&gt; CN2/59.43/AS4809 -&gt; CTGNet/AS23764 -&gt; AS3258</td><td style="text-align:right">36.3 ms</td><td style="text-align:right">36.1 ms</td><td style="text-align:right">0.0%</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; CTGNet/AS23764 -&gt; CN2/59.43/AS4809 -&gt; AS4134</td><td style="text-align:right">41.6 ms</td><td style="text-align:right">41.6 ms</td><td style="text-align:right">0.0%</td></tr></tbody></table>
<p>The optimized route shows the expected premium China Telecom path in both directions. Traffic does not simply exit China Telecom through ordinary international transit. It uses the CN2/59.43 segment and CTGNet/AS23764 before reaching the Riven Cloud Tokyo network.</p>
<h2 id="standard-softbank-transit-comparison">Standard SoftBank transit comparison<a href="#standard-softbank-transit-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>SoftBank is a normal international transit path. PeeringDB lists <a href="https://www.peeringdb.com/asn/17676" title="SoftBank Corp. as AS17676" target="_blank" rel="noopener noreferrer">SoftBank Corp. as AS17676</a>. There is nothing inherently wrong with using SoftBank for general Internet traffic.</p>
<p>For this China Telecom to Tokyo comparison, the baseline path was less suitable:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Telecom user</span></span>
<span class="line"><span>-&gt; China Telecom AS4134</span></span>
<span class="line"><span>-&gt; SoftBank AS17676</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The standard transit baseline from the same June 29, 2026 test set showed higher final-hop RTT than the CTGNet/CN2 optimized path.</p>


























<table><thead><tr><th>Direction</th><th>Standard transit path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>AS4134 -&gt; SoftBank/AS17676 -&gt; AS3258</td><td style="text-align:right">78.1 ms</td><td style="text-align:right">69.6 ms</td><td style="text-align:right">30.0% in this short sample</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; SoftBank/AS17676 -&gt; AS4134</td><td style="text-align:right">74.1 ms</td><td style="text-align:right">63.1 ms</td><td style="text-align:right">10.0% in this short sample</td></tr></tbody></table>
<p>This sample does not prove that SoftBank is bad. It shows the difference between a generic international transit path and a China Telecom matched premium path for this specific Tokyo VPS use case.</p>
<h2 id="side-by-side-results">Side-by-side results<a href="#side-by-side-results" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The RTT gap was large in both directions.</p>


























<table><thead><tr><th>Direction</th><th style="text-align:right">Standard transit avg RTT</th><th style="text-align:right">CTGNet/CN2 optimized avg RTT</th><th style="text-align:right">Reduction</th><th>Optimized path</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td style="text-align:right">78.1 ms</td><td style="text-align:right">36.3 ms</td><td style="text-align:right">41.8 ms lower / about 54% lower</td><td>CN2/AS4809 -&gt; CTGNet/AS23764</td></tr><tr><td>Tokyo to mainland China</td><td style="text-align:right">74.1 ms</td><td style="text-align:right">41.6 ms</td><td style="text-align:right">32.5 ms lower / about 44% lower</td><td>CTGNet/AS23764 -&gt; CN2/AS4809</td></tr></tbody></table>
<p>In this sample, the CTGNet/CN2 optimized route reduced average RTT by more than 40 ms in the China-to-Tokyo direction and more than 30 ms in the return direction. The path itself is the more important signal: the optimized route used the intended China Telecom premium network instead of ordinary international transit.</p>
<p>That route choice is what customers should evaluate when they compare CN2 GIA VPS offers. A low number in one ping test is useful. A clean premium carrier path in both directions is better evidence.</p>
<h2 id="what-customers-feel">What customers feel<a href="#what-customers-feel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Most customers do not buy AS paths for their own sake. They care whether a server feels usable from mainland China.</p>
<p>For China Telecom users, a better matched route can show up in practical ways:</p>
<ul>
<li>SSH sessions feel more responsive.</li>
<li>Control panels and dashboards spend less time waiting on the network.</li>
<li>API calls from mainland China have lower round-trip delay.</li>
<li>Websites load faster for China Telecom users.</li>
<li>Real-time applications have less room for jitter to become visible.</li>
<li>Evening peak is less likely to be dominated by ordinary international transit congestion.</li>
</ul>
<p>The word “likely” matters. Route optimization improves the path. It does not make every application fast, and it does not remove every local access problem.</p>
<h2 id="how-to-read-mtr-without-fooling-yourself">How to read MTR without fooling yourself<a href="#how-to-read-mtr-without-fooling-yourself" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>MTR is useful, but it is easy to overread.</p>
<p>Focus on the final destination, the overall RTT pattern, and the visible AS path. Intermediate packet loss can be caused by ICMP rate limiting, router control-plane protection, or devices that simply do not answer probes. A 100% non-response on an intermediate hop does not prove end-to-end loss when later hops and the final destination still respond normally.</p>
<p>For this comparison, the important checks are:</p>
<ul>
<li>Final-hop average RTT.</li>
<li>Final-hop packet loss.</li>
<li>Whether the visible path uses CN2/59.43/AS4809.</li>
<li>Whether the international handoff uses CTGNet/AS23764.</li>
<li>Whether the return path also uses the premium China Telecom route.</li>
</ul>
<p>Short MTR samples are useful route evidence. They are not a guarantee for every province, every hour, or every future carrier policy change.</p>
<h2 id="what-ctgnetcn2-cannot-guarantee">What CTGNet/CN2 cannot guarantee<a href="#what-ctgnetcn2-cannot-guarantee" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Premium China Telecom routing gives traffic a better path. It does not suspend the laws of the Internet.</p>
<p>Performance can still depend on the user’s province, local access network, time of day, submarine cable events, carrier routing changes, firewall behavior, application protocol, customer-side Wi-Fi, and last-mile conditions.</p>
<p>A clean CTGNet/CN2 path reduces dependence on ordinary international transit. It cannot control every hop before the user reaches China Telecom’s backbone, and it cannot guarantee that every application will use the network efficiently.</p>
<p>Treat route optimization as one important part of a deployment decision, alongside CPU, RAM, NVMe storage, transfer quota, backup policy, and application design.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For China Telecom users, Riven Cloud’s optimized Tokyo route is built around the premium China Telecom path:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>AS4134 access network</span></span>
<span class="line"><span>-&gt; CN2 / AS4809 premium segment</span></span>
<span class="line"><span>-&gt; CTGNet / AS23764 international delivery</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The June 29, 2026 sample showed much lower average RTT than ordinary SoftBank transit in both directions: 36.3 ms versus 78.1 ms from mainland China to Tokyo, and 41.6 ms versus 74.1 ms from Tokyo back to mainland China.</p>
<p>CTGNet and CN2 should be read as parts of the current China Telecom premium routing path, not as interchangeable words. The useful question is whether the actual path shows the premium China Telecom network in both directions.</p>
<p>For a broader carrier view, read <a href="https://sa.net/blog/what-are-china-optimized-routes/" title="What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained">What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained</a>. For the other carrier-specific articles, see <a href="https://sa.net/blog/china-unicom-premium/" title="China Unicom Premium routing">China Unicom Premium routing</a> and <a href="https://sa.net/blog/china-mobile-premium/" title="China Mobile CMIN2 routing">China Mobile CMIN2 routing</a>. To test from Riven Cloud’s Tokyo location, use the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a>.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="China Telecom" term="China Telecom"/>
        <category label="CTGNet" term="CTGNet"/>
        <category label="CN2 GIA" term="CN2 GIA"/>
        <category label="AS4809" term="AS4809"/>
        <category label="AS23764" term="AS23764"/>
        <category label="China-optimized VPS" term="China-optimized VPS"/>
        <category label="Routing" term="Routing"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Is China Unicom Premium? CUP, AS9929, and AS10099 Explained]]></title>
        <id>https://sa.net/blog/china-unicom-premium/</id>
        <link href="https://sa.net/blog/china-unicom-premium/"/>
        <updated>2026-06-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Learn how China Unicom Premium routing works, how AS9929 and AS10099 fit together, and how Riven Cloud routes Tokyo VPS traffic for China Unicom users.]]></summary>
        <content type="html"><![CDATA[<p>Many VPS buyers check China Unicom routing by looking for AS9929. That is a good start, but it is not the full check.</p>
<p>China Unicom Premium, often called CUP in the VPS market, is better understood as a two-part premium path:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Unicom access / AS4837</span></span>
<span class="line"><span>-&gt; China Unicom Premium / AS9929</span></span>
<span class="line"><span>-&gt; China Unicom Global / AS10099</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>The value of the route is in that structure. A good China Unicom Premium path should move traffic from the normal AS4837 access or regional side into AS9929, then use China Unicom Global AS10099 for international delivery before reaching Riven Cloud.</p>
<p>For Riven Cloud Tokyo, the optimized Unicom path in the June 29, 2026 MTR samples was:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>Mainland China to Tokyo:</span></span>
<span class="line"><span>AS4837 -&gt; AS9929 -&gt; AS10099 -&gt; AS3258</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Tokyo to mainland China:</span></span>
<span class="line"><span>AS3258 -&gt; AS10099 -&gt; AS9929 -&gt; AS4837</span></span></code></pre>
<p>Those two directions matter. A route that shows AS9929 in one direction and ordinary transit in the other direction is not the same operational result.</p>
<h2 id="the-china-unicom-networks-customers-should-know">The China Unicom networks customers should know<a href="#the-china-unicom-networks-customers-should-know" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Unicom routing usually involves three ASNs in this discussion.</p>
<p><a href="https://kr.chinaunicomglobal.com/products-dia.php" title="China Unicom Global’s DIA page" target="_blank" rel="noopener noreferrer">China Unicom Global’s DIA page</a> describes AS4837 as China Unicom’s backbone Internet network and says its mainland China DIA service can use the premium bearer network AS9929. The same page describes AS9929 as light loaded and better performing for premium DIA. Its <a href="https://kr.chinaunicomglobal.com/products-ipt-and-peering.php" title="IPT &amp; peering page" target="_blank" rel="noopener noreferrer">IPT &amp; peering page</a> describes AS10099 as China Unicom’s international network for flexible usage patterns and customized routes.</p>
<p>Public PeeringDB records also match the ASN identities: <a href="https://www.peeringdb.com/asn/10099" title="AS10099 is China Unicom Global" target="_blank" rel="noopener noreferrer">AS10099 is China Unicom Global</a>, <a href="https://www.peeringdb.com/asn/9929" title="AS9929 is China Unicom Industrial Internet Backbone" target="_blank" rel="noopener noreferrer">AS9929 is China Unicom Industrial Internet Backbone</a>, and <a href="https://www.peeringdb.com/asn/3258" title="AS3258 is xTom Tokyo" target="_blank" rel="noopener noreferrer">AS3258 is xTom Tokyo</a>.</p>
<p>For route analysis, use this practical map:</p>
<ul>
<li><code>AS4837</code>: China Unicom’s common public Internet backbone and access or regional network.</li>
<li><code>AS9929</code>: the premium Unicom bearer segment, usually the part VPS buyers look for when checking a CUP route.</li>
<li><code>AS10099</code>: China Unicom Global’s international network and the peer-facing side for Riven Cloud.</li>
<li><code>AS3258</code>: the Tokyo network used by the Riven Cloud test node.</li>
</ul>
<p>AS4837 at the beginning of a trace is not a red flag by itself. Many Unicom users begin there. The route becomes interesting when traffic moves from AS4837 into AS9929, then reaches AS10099 before the provider network.</p>
<h2 id="what-china-unicom-premium-means-in-practice">What China Unicom Premium means in practice<a href="#what-china-unicom-premium-means-in-practice" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Unicom Premium is the market name commonly used for the premium Unicom cross-border route. In practice, a strong CUP route usually means AS9929 on the mainland China side and China Unicom Global AS10099 on the international side.</p>
<p>The name alone does not prove much. The path should show how the access network, premium Unicom segment, international network, and provider network connect.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Unicom user</span></span>
<span class="line"><span>-&gt; China Unicom access / AS4837</span></span>
<span class="line"><span>-&gt; China Unicom Premium / AS9929</span></span>
<span class="line"><span>-&gt; China Unicom Global / AS10099</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo / AS3258</span></span></code></pre>
<p>Riven Cloud peers with China Unicom Global through AS10099. China Unicom traffic from mainland China enters the premium AS9929 segment before reaching AS10099 and Riven Cloud.</p>
<h2 id="as9929-and-as10099-are-different-parts-of-the-route">AS9929 and AS10099 are different parts of the route<a href="#as9929-and-as10099-are-different-parts-of-the-route" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>AS9929 and AS10099 are not interchangeable.</p>
<p>AS9929 is the premium Unicom segment customers usually see on the mainland China side. It often appears after AS4837 access or regional hops. In the sample route below, traffic starts on AS4837, then enters AS9929 at hop 8 in the mainland China to Tokyo direction.</p>
<p>AS10099 is China Unicom Global’s international network. It is the AS Riven Cloud peers with for China Unicom Premium, and it handles the international side before traffic reaches Riven Cloud’s Tokyo network.</p>
<p>A useful CUP check looks for both parts. Seeing only one ASN is weaker evidence than seeing the full AS4837 -&gt; AS9929 -&gt; AS10099 -&gt; AS3258 pattern, with the reverse direction matching the same structure.</p>
<h2 id="the-riven-cloud-china-unicom-premium-path">The Riven Cloud China Unicom Premium path<a href="#the-riven-cloud-china-unicom-premium-path" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized China Unicom path for Riven Cloud Tokyo is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Unicom user</span></span>
<span class="line"><span>-&gt; China Unicom AS4837 access / regional network</span></span>
<span class="line"><span>-&gt; China Unicom Premium AS9929</span></span>
<span class="line"><span>-&gt; China Unicom Global AS10099</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo AS3258</span></span></code></pre>
<p>Each part has a role:</p>
<ul>
<li>AS4837 may appear because many users start from ordinary China Unicom access or regional networks.</li>
<li>AS9929 is the premium Unicom segment.</li>
<li>AS10099 is the China Unicom Global international side.</li>
<li>AS3258 is the Riven Cloud Tokyo test network.</li>
</ul>
<p>AS4837 near the access side does not mean the route failed to use premium Unicom routing. The next step is the useful part: whether the path moves into AS9929 and then AS10099.</p>
<h2 id="optimized-route-mtr-sample">Optimized route MTR sample<a href="#optimized-route-mtr-sample" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized MTR samples below were collected on June 29, 2026. The mainland China to Tokyo sample started at <code>2026-06-29T10:20:30+0000</code>. The Tokyo to mainland China sample started at <code>2026-06-29T10:20:36+0000</code>. Each MTR used 10 probes.</p>
<p>The visible AS path matched the expected China Unicom Premium structure in both directions.</p>


























<table><thead><tr><th>Direction</th><th>Visible premium path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>AS4837 -&gt; AS9929 -&gt; AS10099 -&gt; AS3258</td><td style="text-align:right">37.4 ms</td><td style="text-align:right">37.1 ms</td><td style="text-align:right">0.0%</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; AS10099 -&gt; AS9929 -&gt; AS4837</td><td style="text-align:right">36.6 ms</td><td style="text-align:right">36.6 ms</td><td style="text-align:right">0.0%</td></tr></tbody></table>
<p>The mainland China to Tokyo MTR showed AS4837 on the access side, then AS9929, then AS10099, and the final Riven Cloud Tokyo endpoint on AS3258. The return MTR showed AS3258 first, then AS10099, then AS9929, and the final China Unicom endpoint on AS4837.</p>
<p>That is the important production signal: the route uses AS9929 and AS10099 in both directions, instead of leaving China Unicom through a generic international transit path.</p>
<h2 id="standard-softbank-transit-comparison">Standard SoftBank transit comparison<a href="#standard-softbank-transit-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The same June 29, 2026 comparison set also included a standard SoftBank transit baseline. SoftBank AS17676 is a normal international transit network. The point of the comparison is route matching for China Unicom users, not a claim that SoftBank is a bad network.</p>
<p>The baseline path was:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China Unicom user</span></span>
<span class="line"><span>-&gt; China Unicom AS4837</span></span>
<span class="line"><span>-&gt; SoftBank AS17676</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo AS3258</span></span></code></pre>
<p>Cleaned final-hop results from that baseline were:</p>


























<table><thead><tr><th>Direction</th><th>Standard transit path</th><th style="text-align:right">Final-hop avg RTT</th><th style="text-align:right">Best RTT</th><th style="text-align:right">Final-hop packet loss</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td>AS4837 -&gt; SoftBank/AS17676 -&gt; AS3258</td><td style="text-align:right">53.1 ms</td><td style="text-align:right">41.1 ms</td><td style="text-align:right">0.0%</td></tr><tr><td>Tokyo to mainland China</td><td>AS3258 -&gt; SoftBank/AS17676 -&gt; AS4837</td><td style="text-align:right">57.0 ms</td><td style="text-align:right">47.8 ms</td><td style="text-align:right">0.0%</td></tr></tbody></table>
<p>For this Unicom to Tokyo sample, the standard transit path had higher average RTT than the China Unicom Premium route in both directions.</p>
<h2 id="side-by-side-results">Side-by-side results<a href="#side-by-side-results" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The optimized path reduced average RTT in both directions.</p>


























<table><thead><tr><th>Direction</th><th style="text-align:right">Standard transit avg RTT</th><th style="text-align:right">CUP optimized avg RTT</th><th style="text-align:right">Reduction</th><th>Optimized path</th></tr></thead><tbody><tr><td>Mainland China to Tokyo</td><td style="text-align:right">53.1 ms</td><td style="text-align:right">37.4 ms</td><td style="text-align:right">15.7 ms lower / about 30% lower</td><td>AS9929 -&gt; AS10099</td></tr><tr><td>Tokyo to mainland China</td><td style="text-align:right">57.0 ms</td><td style="text-align:right">36.6 ms</td><td style="text-align:right">20.4 ms lower / about 36% lower</td><td>AS10099 -&gt; AS9929</td></tr></tbody></table>
<p>The RTT reduction is useful, but the cleaner route structure is the bigger signal. The optimized route used the intended China Unicom premium path rather than ordinary international transit.</p>
<p>For customers comparing AS9929 VPS or CUP VPS offers, this is the check that matters: AS4837 access, AS9929 premium segment, China Unicom Global AS10099, and the provider AS in both directions.</p>
<h2 id="why-this-matters-for-vps-users">Why this matters for VPS users<a href="#why-this-matters-for-vps-users" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Most customers do not care about AS paths until the network starts wasting their time.</p>
<p>For China Unicom users, a carrier-matched path can improve ordinary daily work:</p>
<ul>
<li>SSH sessions feel more responsive.</li>
<li>Websites load faster for users on China Unicom.</li>
<li>API calls from mainland China spend less time waiting on the network.</li>
<li>Control panels and remote desktop sessions feel less sticky.</li>
<li>Latency-sensitive applications get a cleaner path to Tokyo.</li>
<li>The service depends less on ordinary international transit during busy hours.</li>
</ul>
<p>The result still depends on the user’s local line and application behavior. Route optimization gives the traffic a better path. It does not make every last-mile network perfect.</p>
<h2 id="how-to-check-whether-a-route-is-really-china-unicom-premium">How to check whether a route is really China Unicom Premium<a href="#how-to-check-whether-a-route-is-really-china-unicom-premium" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A good Unicom premium route often shows this pattern:</p>
<ul>
<li>AS4837 near the access side.</li>
<li>AS9929 as the premium Unicom segment.</li>
<li>AS10099 as the China Unicom Global international network.</li>
<li>The provider AS after AS10099.</li>
</ul>
<p>For Riven Cloud Tokyo, the expected optimized pattern is:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>China to Tokyo:</span></span>
<span class="line"><span>AS4837 -&gt; AS9929 -&gt; AS10099 -&gt; AS3258</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Tokyo to China:</span></span>
<span class="line"><span>AS3258 -&gt; AS10099 -&gt; AS9929 -&gt; AS4837</span></span></code></pre>
<p>Exact router IPs, hostnames, and city codes may change. The AS-level pattern is the part to check first.</p>
<h2 id="how-to-read-mtr-without-chasing-the-wrong-problem">How to read MTR without chasing the wrong problem<a href="#how-to-read-mtr-without-chasing-the-wrong-problem" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>MTR is useful, but intermediate hops can mislead.</p>
<p>Routers may rate-limit ICMP, deprioritize control-plane replies, or ignore probes while still forwarding customer traffic normally. A 100% non-response on an intermediate hop does not prove end-to-end packet loss when later hops and the final destination continue to respond.</p>
<p>The Tokyo to mainland China sample shows why this matters. Hop 5 reported 50.0% loss and a very high average latency, but later hops returned to normal and the final destination showed 36.6 ms average RTT with 0.0% packet loss. Treat that kind of isolated intermediate behavior as a diagnostic clue, not as final proof of customer traffic loss.</p>
<p>When checking CUP routes, focus on:</p>
<ul>
<li>Final-hop average RTT.</li>
<li>Final-hop packet loss.</li>
<li>Whether the path shows AS9929 and AS10099.</li>
<li>Whether the return direction also uses AS10099 and AS9929.</li>
<li>Whether the same loss pattern continues to the final destination.</li>
</ul>
<p>Short MTR samples are useful route evidence. They are not an SLA for every province, every hour, or every future routing change.</p>
<h2 id="what-china-unicom-premium-cannot-guarantee">What China Unicom Premium cannot guarantee<a href="#what-china-unicom-premium-cannot-guarantee" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Unicom Premium gives traffic a better path. It does not make every last-mile network perfect.</p>
<p>Performance can still depend on province, local broadband quality, office or residential network conditions, last-mile congestion, time of day, carrier routing changes, international cable incidents, application protocol, customer-side Wi-Fi, and customer router behavior.</p>
<p>CUP reduces dependence on ordinary international transit for China Unicom users. It cannot control every access network before AS4837, and it cannot fix an application that handles latency poorly.</p>
<p>Treat the route as one part of the deployment decision, alongside CPU, RAM, NVMe storage, monthly transfer, backup policy, and application design.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For China Unicom users, Riven Cloud’s optimized Tokyo route is designed around the real China Unicom Premium path:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text"><code><span class="line"><span>AS4837 access / regional network</span></span>
<span class="line"><span>-&gt; AS9929 premium segment</span></span>
<span class="line"><span>-&gt; China Unicom Global AS10099</span></span>
<span class="line"><span>-&gt; Riven Cloud Tokyo AS3258</span></span></code></pre>
<p>In the June 29, 2026 MTR samples, that path delivered 37.4 ms average RTT from mainland China to Tokyo and 36.6 ms average RTT from Tokyo back to mainland China, both with 0.0% final-hop packet loss.</p>
<p>The same-day standard SoftBank transit baseline averaged 53.1 ms from mainland China to Tokyo and 57.0 ms in the return direction. The lower RTT helps, but the route evidence is the real point: the optimized route shows the expected AS9929 and AS10099 path in both directions.</p>
<p>For a broader carrier view, read <a href="https://sa.net/blog/what-are-china-optimized-routes/" title="What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained">What are China-optimized routes? CTGNet/CN2 GIA, CUP, and CMIN2 explained</a>. For the other carrier-specific articles, see <a href="https://sa.net/blog/china-telecom-premium/" title="China Telecom premium routing">China Telecom premium routing</a> and <a href="https://sa.net/blog/china-mobile-premium/" title="China Mobile CMIN2 routing">China Mobile CMIN2 routing</a>. To test from Riven Cloud’s Tokyo location, use the <a href="https://tokyo.lg.sa.net/" title="Tokyo Looking Glass" target="_blank" rel="noopener noreferrer">Tokyo Looking Glass</a>.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="China Unicom" term="China Unicom"/>
        <category label="China Unicom Premium" term="China Unicom Premium"/>
        <category label="CUP" term="CUP"/>
        <category label="AS9929" term="AS9929"/>
        <category label="AS10099" term="AS10099"/>
        <category label="China-optimized VPS" term="China-optimized VPS"/>
        <category label="Routing" term="Routing"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Are China-Optimized Routes? CTGNet/CN2 GIA, CUP, and CMIN2 Explained]]></title>
        <id>https://sa.net/blog/what-are-china-optimized-routes/</id>
        <link href="https://sa.net/blog/what-are-china-optimized-routes/"/>
        <updated>2026-06-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Learn what three-network optimization means for mainland China users and how Riven Cloud uses CTGNet/CN2 GIA, China Unicom Premium, and CMIN2 to improve VPS connectivity.]]></summary>
        <content type="html"><![CDATA[<p>Many VPS providers use “China optimized” to mean any route that reaches mainland China reasonably well. For Tokyo, that is too vague.</p>
<p>A China Telecom home connection, a China Unicom office line, and a China Mobile phone can take different international paths to the same VPS. A route that looks fine for one carrier may be the wrong path for another.</p>
<p>In the mainland China VPS market, “three-network optimization” means treating the three major carrier networks separately:</p>
<ul>
<li>China Telecom</li>
<li>China Unicom</li>
<li>China Mobile</li>
</ul>
<p>Riven Cloud’s optimized Tokyo routing is built around that model. China Telecom traffic uses the CN2/AS4809 domestic premium path and reaches Riven Cloud through CTGNet/AS23764. China Unicom traffic uses AS9929 on the mainland China side and reaches Riven Cloud through China Unicom Global AS10099. China Mobile traffic uses AS9808 domestically and reaches Riven Cloud through CMIN2/AS58807.</p>
<p>Lower latency is part of the story, but carrier matching matters just as much. A good China route should avoid pushing all three carriers through the same ordinary transit path and hoping the result holds during peak hours.</p>
<p>The comparison data below comes from cleaned MTR samples collected on June 29, 2026. Exact customer endpoints, VPS addresses, router IPs, private IPs, and hostnames have been redacted. The article keeps the parts that matter for route analysis: carrier labels, ASNs, direction, final-hop average RTT, final-hop packet loss, and visible AS-level paths.</p>
<h2 id="what-are-the-three-networks-in-china">What are the three networks in China?<a href="#what-are-the-three-networks-in-china" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Telecom, China Unicom, and China Mobile are the three carrier networks most VPS buyers care about when serving users in mainland China.</p>
<p>That matters because a “China route” is not a single path. A user in Shanghai on China Telecom, a developer in Beijing on China Unicom, and a phone on China Mobile can take very different routes to the same VPS in Tokyo. A path that works well for one carrier may be ordinary, indirect, or congested for another.</p>
<p>A China-optimized route needs to be carrier-specific. One decent international transit provider is not the same thing as three-network optimization.</p>
<h2 id="why-ordinary-international-transit-is-not-enough">Why ordinary international transit is not enough<a href="#why-ordinary-international-transit-is-not-enough" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Standard global transit can be good for general international traffic. For users in North America, Europe, Japan, Singapore, and many other markets, a well-connected transit blend may be perfectly reasonable.</p>
<p>Mainland China traffic is different. The route depends heavily on carrier-specific international gateways, domestic backbone policy, handoff points, and the relationship between the overseas network and each Chinese carrier.</p>
<p>Ordinary transit may enter China through a less ideal carrier path. It may take a detour through another region. It may work acceptably at one time of day and become inconsistent during evening peak. It may also be asymmetric: the route from China to Tokyo can look different from the route from Tokyo back to China.</p>
<p>Bandwidth is only one part of network quality. For many applications, latency, packet loss, jitter, and route consistency matter more than a large port number on a plan page. A 1 Gbps port does not help much if packets reach the wrong international gateway or bounce through a congested carrier path.</p>
<h2 id="china-telecom-ctgnet-and-cn2-gia">China Telecom: CTGNet and CN2 GIA<a href="#china-telecom-ctgnet-and-cn2-gia" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China Telecom is the easiest one to mislabel.</p>
<p>CTGNet is China Telecom’s newer premium international network, associated with AS23764. CN2, commonly discussed in the VPS market as CN2 GIA, is tied to the premium China Telecom backbone segment associated with AS4809. These are related in the customer experience, but they are not the same phrase with two names.</p>
<p>Riven Cloud’s path should not be described as “CTGNet equals CN2 GIA.” The domestic premium segment and the international delivery path are different parts of the route. In this setup, China Telecom traffic uses the CN2/AS4809 domestic premium segment and reaches Riven Cloud through CTGNet/AS23764.</p>
<p>Riven Cloud peers with China Telecom through CTGNet AS23764. On the mainland China side, traffic can traverse the CN2/59.43 segment associated with AS4809 before it reaches the CTGNet AS23764 edge and then Riven Cloud’s Tokyo network.</p>
<p>As of June 29, 2026, PeeringDB lists <a href="https://www.peeringdb.com/net/17755" title="CTGNet as AS23764" target="_blank" rel="noopener noreferrer">CTGNet as AS23764</a>. That public record matches the peer-facing ASN discussed here.</p>
<p>In our sample MTR tests, the optimized China Telecom route showed an average final-hop RTT of 36.3 ms from a China Telecom access sample to the Riven Cloud Tokyo optimized test node, with 36.1 ms best RTT and 0.0% final-hop packet loss. In the reverse direction, from Tokyo back to the China Telecom access sample, the optimized route showed 41.6 ms average final-hop RTT, 41.6 ms best RTT, and 0.0% final-hop packet loss.</p>
<p>The ordinary SoftBank transit baseline for the same carrier sample used AS4134 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258 in the China-to-Tokyo direction, and Riven Cloud AS3258 -&gt; SoftBank AS17676 -&gt; AS4134 in the return direction. The final hop averaged 78.1 ms with 30.0% packet loss from China to Tokyo, and 74.1 ms with 10.0% packet loss from Tokyo back to China.</p>



































<table><thead><tr><th>Measurement</th><th>Ordinary SoftBank transit</th><th>CTGNet/CN2 optimized route</th></tr></thead><tbody><tr><td>China to Tokyo AS path</td><td>AS4134 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258</td><td>AS4134 access -&gt; CN2/AS4809 -&gt; CTGNet/AS23764 -&gt; Riven Cloud AS3258</td></tr><tr><td>China to Tokyo final hop</td><td>78.1 ms avg / 69.6 ms best / 30.0% loss</td><td>36.3 ms avg / 36.1 ms best / 0.0% loss</td></tr><tr><td>Tokyo to China AS path</td><td>Riven Cloud AS3258 -&gt; SoftBank AS17676 -&gt; AS4134</td><td>Riven Cloud AS3258 -&gt; CTGNet/AS23764 -&gt; CN2/AS4809 -&gt; AS4134</td></tr><tr><td>Tokyo to China final hop</td><td>74.1 ms avg / 63.1 ms best / 10.0% loss</td><td>41.6 ms avg / 41.6 ms best / 0.0% loss</td></tr><tr><td>Result in this sample</td><td>Standard transit was 41.8 ms slower China to Tokyo and 32.5 ms slower Tokyo to China</td><td>The optimized path was about 54% lower RTT China to Tokyo and 44% lower RTT Tokyo to China</td></tr></tbody></table>
<p>This does not mean every China Telecom user will always see those exact numbers. It does show the route difference clearly: CN2/AS4809 and CTGNet/AS23764 on the optimized path, compared with ordinary SoftBank transit on the baseline path.</p>
<p>For the carrier-specific breakdown, read <a href="https://sa.net/blog/china-telecom-premium/" title="What is CTGNet / CN2 GIA? China Telecom premium routing explained">What is CTGNet / CN2 GIA? China Telecom premium routing explained</a>.</p>
<h2 id="china-unicom-cup-and-china-unicom-premium">China Unicom: CUP and China Unicom Premium<a href="#china-unicom-cup-and-china-unicom-premium" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CUP means China Unicom Premium.</p>
<p>For Riven Cloud’s optimized China Unicom path, the mainland China side uses AS9929 as the premium backbone and international exit segment. Riven Cloud peers with China Unicom Global through AS10099. AS4837 may still appear before traffic enters AS9929, because AS4837 is commonly seen as an ordinary China Unicom regional or access network.</p>
<p>The accurate shorthand is:</p>
<p>China Unicom traffic uses AS9929 on the mainland China side and reaches Riven Cloud through China Unicom Global AS10099.</p>
<p>As of June 29, 2026, PeeringDB lists <a href="https://www.peeringdb.com/net/7268" title="China Unicom Global as AS10099" target="_blank" rel="noopener noreferrer">China Unicom Global as AS10099</a>. In the optimized sample, the visible AS path from mainland China to Tokyo was AS4837 -&gt; AS9929 -&gt; AS10099 -&gt; Riven Cloud AS3258. The return direction showed Riven Cloud AS3258 -&gt; AS10099 -&gt; AS9929 -&gt; AS4837.</p>
<p>The useful part is where AS9929 appears. AS4837 may still show up near the access side, but the cross-border segment moves onto the premium Unicom path through AS9929 and AS10099.</p>
<p>In our sample, the optimized China Unicom route averaged 37.4 ms from China to Tokyo, with 37.1 ms best RTT and 0.0% final-hop packet loss. The return direction averaged 36.6 ms, with 36.6 ms best RTT and 0.0% final-hop packet loss.</p>
<p>The ordinary SoftBank transit baseline used AS4837 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258 from China to Tokyo, and Riven Cloud AS3258 -&gt; SoftBank AS17676 -&gt; AS4837 in the return direction. It averaged 53.1 ms from China to Tokyo and 57.0 ms from Tokyo back to China, both with 0.0% final-hop packet loss.</p>



































<table><thead><tr><th>Measurement</th><th>Ordinary SoftBank transit</th><th>CUP/AS9929 optimized route</th></tr></thead><tbody><tr><td>China to Tokyo AS path</td><td>AS4837 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258</td><td>AS4837 -&gt; AS9929 -&gt; China Unicom Global AS10099 -&gt; Riven Cloud AS3258</td></tr><tr><td>China to Tokyo final hop</td><td>53.1 ms avg / 41.1 ms best / 0.0% loss</td><td>37.4 ms avg / 37.1 ms best / 0.0% loss</td></tr><tr><td>Tokyo to China AS path</td><td>Riven Cloud AS3258 -&gt; SoftBank AS17676 -&gt; AS4837</td><td>Riven Cloud AS3258 -&gt; China Unicom Global AS10099 -&gt; AS9929 -&gt; AS4837</td></tr><tr><td>Tokyo to China final hop</td><td>57.0 ms avg / 47.8 ms best / 0.0% loss</td><td>36.6 ms avg / 36.6 ms best / 0.0% loss</td></tr><tr><td>Result in this sample</td><td>Standard transit was 15.7 ms slower China to Tokyo and 20.4 ms slower Tokyo to China</td><td>The optimized path was about 30% lower RTT China to Tokyo and 36% lower RTT Tokyo to China</td></tr></tbody></table>
<p>For the carrier-specific breakdown, read <a href="https://sa.net/blog/china-unicom-premium/" title="What is China Unicom Premium? CUP, AS9929, and AS10099 explained">What is China Unicom Premium? CUP, AS9929, and AS10099 explained</a>.</p>
<h2 id="china-mobile-cmin2">China Mobile: CMIN2<a href="#china-mobile-cmin2" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CMIN2 means China Mobile International N2. It is the important path to look for when evaluating optimized China Mobile routing.</p>
<p>Riven Cloud peers with CMIN2 through AS58807. Mainland China traffic uses AS9808 domestically before reaching AS58807. In contrast, ordinary China Mobile international routes may use older CMI paths such as AS58453, often with additional transit such as SoftBank AS17676 in the baseline route.</p>
<p>The accurate shorthand is:</p>
<p>China Mobile traffic uses AS9808 domestically and reaches Riven Cloud through CMIN2/AS58807.</p>
<p>As of June 29, 2026, PeeringDB lists <a href="https://www.peeringdb.com/net/22581" title="China Mobile International - NII as AS58807" target="_blank" rel="noopener noreferrer">China Mobile International - NII as AS58807</a>, with CMIN2 as its listed aka.</p>
<p>In our optimized sample, the visible AS path from mainland China to Tokyo was China Mobile regional/access network -&gt; AS9808 -&gt; CMIN2 AS58807 -&gt; Riven Cloud AS3258. The return direction showed Riven Cloud AS3258 -&gt; CMIN2 AS58807 -&gt; AS9808 -&gt; China Mobile regional/access network.</p>
<p>The optimized route averaged 40.0 ms from China to Tokyo, with 39.8 ms best RTT and 0.0% final-hop packet loss. The return direction averaged 44.7 ms, with 44.6 ms best RTT and 0.0% final-hop packet loss.</p>
<p>The ordinary SoftBank transit baseline used China Mobile regional/access network -&gt; AS9808 -&gt; ordinary CMI AS58453 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258 from China to Tokyo, and the same general chain in reverse from Tokyo back to China. It averaged 72.2 ms from China to Tokyo and 70.0 ms from Tokyo back to China, both with 0.0% final-hop packet loss.</p>



































<table><thead><tr><th>Measurement</th><th>Ordinary SoftBank transit</th><th>CMIN2 optimized route</th></tr></thead><tbody><tr><td>China to Tokyo AS path</td><td>China Mobile regional/access -&gt; AS9808 -&gt; ordinary CMI AS58453 -&gt; SoftBank AS17676 -&gt; Riven Cloud AS3258</td><td>China Mobile regional/access -&gt; AS9808 -&gt; CMIN2 AS58807 -&gt; Riven Cloud AS3258</td></tr><tr><td>China to Tokyo final hop</td><td>72.2 ms avg / 72.0 ms best / 0.0% loss</td><td>40.0 ms avg / 39.8 ms best / 0.0% loss</td></tr><tr><td>Tokyo to China AS path</td><td>Riven Cloud AS3258 -&gt; SoftBank AS17676 -&gt; ordinary CMI AS58453 -&gt; AS9808 -&gt; China Mobile regional/access</td><td>Riven Cloud AS3258 -&gt; CMIN2 AS58807 -&gt; AS9808 -&gt; China Mobile regional/access</td></tr><tr><td>Tokyo to China final hop</td><td>70.0 ms avg / 69.9 ms best / 0.0% loss</td><td>44.7 ms avg / 44.6 ms best / 0.0% loss</td></tr><tr><td>Result in this sample</td><td>Standard transit was 32.2 ms slower China to Tokyo and 25.3 ms slower Tokyo to China</td><td>The optimized path was about 45% lower RTT China to Tokyo and 36% lower RTT Tokyo to China</td></tr></tbody></table>
<p>For China Mobile customers, AS58807 is the signal to look for. The optimized sample uses CMIN2 instead of the ordinary AS58453/SoftBank transit path.</p>
<p>For the carrier-specific breakdown, read <a href="https://sa.net/blog/china-mobile-premium/" title="What is CMIN2? China Mobile International N2 and AS58807 explained">What is CMIN2? China Mobile International N2 and AS58807 explained</a>.</p>
<h2 id="how-to-read-these-mtr-samples">How to read these MTR samples<a href="#how-to-read-these-mtr-samples" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The carrier sections above use cleaned sample MTR results collected on June 29, 2026. We publish carrier names, AS-level paths, direction, final-hop average RTT, and final-hop packet loss. We do not publish customer IP addresses, VPS IP addresses, router IPs, private IPs, hostnames, or full raw MTR output.</p>
<p>Treat these as point-in-time samples, not universal guarantees. The useful checks are simple: final-hop RTT, final-hop packet loss, visible AS path, and whether the route lands on the expected carrier network. In these samples, the optimized route had lower average final-hop RTT in both directions for all three carriers. The optimized samples also showed 0.0% final-hop packet loss, while the ordinary China Telecom baseline showed final-hop loss in this short sample.</p>
<p>Intermediate MTR packet loss, including 100% non-response on an intermediate hop, does not automatically mean real end-to-end packet loss. Many routers rate-limit or ignore ICMP while still forwarding customer traffic normally.</p>
<p>Use the ordinary SoftBank examples as a standard international transit baseline. SoftBank AS17676 is a major network. For this Tokyo-to-mainland-China use case, the carrier-specific premium paths fit better than a general transit path.</p>
<h2 id="what-customers-actually-feel">What customers actually feel<a href="#what-customers-actually-feel" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Most customers do not buy a route table. They buy a VPS because they want a website, API, control panel, game server, proxy service, development box, or internal tool to feel usable from mainland China.</p>
<p>The difference usually shows up in boring places: SSH stops feeling sticky, admin panels load without the first click hanging, and API calls from mainland China spend less time waiting on the network. It will not make every app fast, but it removes one common source of delay.</p>
<p>Peak-hour consistency is often the bigger win. A route that looks acceptable in the afternoon can become a problem in the evening if it depends on a congested international path. Matching China Telecom, China Unicom, and China Mobile to their own premium paths reduces that risk.</p>
<p>It does not remove every risk or guarantee the same latency for every province, office, mobile network, or residential line. Packet loss can still happen. The VPS starts from a better network position.</p>
<h2 id="what-china-optimized-routing-cannot-guarantee">What China-optimized routing cannot guarantee<a href="#what-china-optimized-routing-cannot-guarantee" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China-optimized routing improves path selection. It is not a physics exemption or a replacement for operational monitoring.</p>
<p>Performance can still depend on province, local ISP, last-mile quality, time of day, cross-border congestion, cable incidents, carrier routing changes, and the application itself. A user on weak Wi-Fi behind an overloaded local access network can still have a poor experience even if the international route is excellent.</p>
<p>MTR is a useful diagnostic tool, but it is not a full SLA. It shows a path sample at a point in time. That sample is valuable when it confirms the expected AS path and final-hop behavior, but it should not be treated as absolute proof that every future flow will behave identically.</p>
<p>The practical goal is better route selection: China Telecom through CN2/AS4809 and CTGNet/AS23764, China Unicom through AS9929 and China Unicom Global AS10099, and China Mobile through AS9808 and CMIN2/AS58807.</p>
<h2 id="choosing-a-tokyo-vps-for-mainland-china-users">Choosing a Tokyo VPS for mainland China users<a href="#choosing-a-tokyo-vps-for-mainland-china-users" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a normal VPS buyer, CPU, RAM, NVMe storage, and transfer quota still matter. A busy application needs enough compute and disk performance. A high-traffic service needs a plan with enough monthly transfer. A production system needs backups and sensible operations.</p>
<p>For mainland China users, the network path can matter just as much.</p>
<p>A generic Japan VPS may be physically close to China, but physical distance is only one part of the route. The carrier path decides whether traffic enters the right Chinese backbone, whether the return direction is sensible, and whether the route remains stable when ordinary international transit gets busy.</p>
<p>Riven Cloud’s China-optimized Tokyo VPS routing is designed for customers who need that carrier-aware path: CTGNet/CN2 GIA for China Telecom, CUP for China Unicom, and CMIN2 for China Mobile.</p>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China-optimized routing means matching each major mainland China carrier with its own premium path.</p>
<p>For Riven Cloud’s optimized Tokyo route, that means China Telecom traffic uses CN2/AS4809 domestically and CTGNet/AS23764 internationally, China Unicom traffic uses AS9929 and China Unicom Global AS10099, and China Mobile traffic uses AS9808 and CMIN2/AS58807.</p>
<p>That is what “three-network optimization” should mean in practice. It is not a slogan and it is not one generic overseas route. It is separate carrier engineering for China Telecom, China Unicom, and China Mobile.</p>
<p>If your users are in mainland China, the route can matter as much as CPU, RAM, and storage. Riven Cloud’s Tokyo route is built around that reality: China Telecom, China Unicom, and China Mobile each need their own path.</p>
<p>If you need a VPS with three-network optimized routing, you can order one at <a href="https://sa.net/" title="sa.net" target="_blank" rel="noopener noreferrer">sa.net</a>.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="China-optimized VPS" term="China-optimized VPS"/>
        <category label="CTGNet" term="CTGNet"/>
        <category label="CN2 GIA" term="CN2 GIA"/>
        <category label="China Unicom Premium" term="China Unicom Premium"/>
        <category label="CUP" term="CUP"/>
        <category label="CMIN2" term="CMIN2"/>
        <category label="BGP" term="BGP"/>
        <category label="China Telecom" term="China Telecom"/>
        <category label="China Unicom" term="China Unicom"/>
        <category label="China Mobile" term="China Mobile"/>
    </entry>
</feed>