<?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-09-29T16:15:52.618Z</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[How to Explain "Last-Mile" China Routing Problems to a Client Who Blames Your Server]]></title>
        <id>https://sa.net/blog/last-mile-china-routing-problems/</id>
        <link href="https://sa.net/blog/last-mile-china-routing-problems/"/>
        <updated>2026-09-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[When a client in China reports slow load times, the instinct is to blame the server. Often the real bottleneck is the last mile, the final leg of the route inside China's own domestic networks, and explaining that clearly is half the job.]]></summary>
        <content type="html"><![CDATA[<p>A client in Shanghai tells you the app is slow. Your monitoring says response times are fine, your server load is unremarkable, and every test you run from outside China looks healthy.</p>
<p>Both of you are looking at real data and reaching different conclusions, because you’re each seeing a different segment of the same path. The client’s experience includes a leg you can’t see from your side, and your metrics cover a leg they can’t see from theirs.</p>
<p>That missing segment is usually the last mile, and being able to explain it convincingly matters as much as diagnosing it. A client who feels they’re being deflected won’t accept a technically correct answer.</p>
<h2 id="what-last-mile-actually-means-in-a-china-routing-context">What “last mile” actually means in a China routing context<a href="#what-last-mile-actually-means-in-a-china-routing-context" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Think of the path in two halves. The international leg carries traffic from your server to the point where it enters Chinese domestic carrier infrastructure, and that’s the segment a premium route like <a href="https://sa.net/network/" title="CTGNet, CUP, or CMIN2" target="_blank" rel="noopener noreferrer">CTGNet, CUP, or CMIN2</a> exists to optimize.</p>
<p>The last mile is everything after that: the domestic hops from the carrier’s border network through provincial infrastructure to the client’s actual connection, whether that’s a fixed broadband line or a mobile tower. It’s typically a small number of hops, and it’s entirely inside networks that no foreign host has any commercial relationship with.</p>
<p>This is why a route can be genuinely excellent and still deliver a poor experience. The two halves fail independently, and optimizing one does nothing for the other. A clean international leg into a congested provincial network produces exactly the complaint you’re fielding.</p>
<h2 id="why-this-gets-misattributed-to-your-server">Why this gets misattributed to your server<a href="#why-this-gets-misattributed-to-your-server" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>From the client’s side, the only variable they can see is your service. They open the app, it’s slow, and the app is the thing you’re responsible for. The inference is reasonable even though it’s wrong.</p>
<p>It’s also reinforced by the fact that other things work fine for them. Domestic Chinese services feel fast because their traffic never leaves the country, skipping the international leg and the border handoff entirely. That contrast makes your service look uniquely broken, when the real difference is that domestic traffic isn’t making the same journey at all.</p>
<p>The asymmetry in available evidence compounds it. You have server metrics, and they have lived experience, and neither of those datasets contains the hop where the problem lives. This is why the conversation tends to stall until someone produces a traceroute.</p>
<h2 id="how-to-actually-explain-this-without-sounding-like-youre-deflecting-blame">How to actually explain this without sounding like you’re deflecting blame<a href="#how-to-actually-explain-this-without-sounding-like-youre-deflecting-blame" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The single most effective move is to stop describing and start showing. Run an MTR from a public Looking Glass toward their network, or ask them to run one toward your server, and look at the output together. Point at the specific hop where latency jumps or loss appears.</p>
<p>Then narrate it in plain terms rather than protocol language. Something like: “Traffic leaves our server and stays on China Telecom’s premium backbone for most of the trip, and you can see the latency staying flat through those hops. Here, at hop eleven, it enters the provincial network that serves your building, and that’s where it jumps from 60 milliseconds to 210. Everything before that is ours, and it’s clean. That hop isn’t something any host outside China can route around.”</p>
<p>Say what you can’t control, explicitly. Counterintuitively, this builds more confidence than hedging does, because a technically capable client can tell the difference between an honest boundary and an excuse. Claiming you’ll “look into optimizing the route” when the bottleneck is inside a Chinese provincial network sets up a follow-up conversation where you have nothing new to report.</p>
<p>Finally, avoid the temptation to end there. “It’s the last mile, nothing we can do” is accurate and unsatisfying, and there are usually two or three real checks left to run.</p>
<h2 id="what-can-still-be-done-about-last-mile-issues">What can still be done about last-mile issues<a href="#what-can-still-be-done-about-last-mile-issues" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The most common fixable cause hiding behind a last-mile complaint is a carrier mismatch. Ask which carrier the client is actually on, <a href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/" title="China Telecom, China Unicom, or China Mobile" target="_blank" rel="noopener noreferrer">China Telecom, China Unicom, or China Mobile</a>, and confirm your route is optimized for that specific one. If they can give you their IP address, a whois lookup on it returns the AS number that announces the prefix, and <a href="https://www.peeringdb.com/" title="PeeringDB" target="_blank" rel="noopener noreferrer">PeeringDB</a> will tell you which network that AS belongs to. A server with an excellent China Telecom path serving a China Unicom user is a genuinely suboptimal setup, and it’s fixable rather than a fact of life.</p>
<p>Second, test across the day. Domestic congestion inside China varies substantially between off-peak and business hours, and a complaint that only appears between 8pm and 11pm local time is a congestion pattern rather than a routing defect. Establishing that with timestamped data also reframes the conversation productively: you’re now discussing predictable variance instead of an unexplained failure.</p>
<p>Third, look at what your application does under high latency. An app making twenty sequential requests where five would do will feel dramatically worse on a high-latency path than on a local one, and that part genuinely is yours to improve. Reducing round trips, enabling connection reuse, and moving static assets closer to users all mitigate a last mile you can’t fix directly.</p>
<p>It’s also worth setting expectations about verification itself. A Looking Glass reading reflects one moment, not a guaranteed daily average, which is the same reason it pays to <a href="https://sa.net/blog/questions-ctgnet-cn2-gia-claims/" title="ask pointed questions" target="_blank" rel="noopener noreferrer">ask pointed questions</a> of any provider rather than treating one good test as a promise.</p>
<h2 id="a-short-script-for-the-conversation">A short script for the conversation<a href="#a-short-script-for-the-conversation" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>If it helps, the structure that tends to work is: acknowledge, show, delineate, then act. Acknowledge that what they’re experiencing is real rather than opening with your metrics. Show the traceroute and point at the hop. Delineate which segment is yours and which isn’t, without softening it. Then name the specific things you will check, the carrier match, the time-of-day pattern, and your application’s round-trip behavior.</p>
<p>What that sequence avoids is the failure mode where the client hears “not our problem” as the first substantive statement. The technical content is identical; the order changes whether it lands as diagnosis or as deflection.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Last-mile congestion inside China’s domestic networks is a real and common cause of complaints that arrive pointed at your server. It’s also genuinely outside the reach of any host operating from outside the country.</p>
<p>The fastest path through the conversation isn’t defending your infrastructure. It’s showing the client exactly where in the path the time is going, being straightforward about which parts you own, and following up on the handful of checks, carrier match, timing pattern, and application round trips, that actually are yours.</p>
<p>Thanks for reading! Whether you’re walking a client through a traceroute or working out which hops are genuinely yours to fix, knowing where your control ends saves a lot of wasted effort. If you’re looking for China routing you can verify yourself rather than take on faith, Riven Cloud runs <a href="https://sa.net/pricing/" title="KVM VPS in Tokyo and Singapore" target="_blank" rel="noopener noreferrer">KVM VPS in Tokyo and Singapore</a> with all three named premium paths on every plan: CTGNet on China Telecom, CUP on China Unicom, and CMIN2 on China Mobile, each testable through a public <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> before you buy.</p>
<p><em><strong>Ready to see the numbers for your own traffic? <a href="https://portal.sa.net/store/cloud-server/" title="Deploy a server" target="_blank" rel="noopener noreferrer">Deploy a server</a> or <a href="https://sa.net/contact/" title="contact our team" target="_blank" rel="noopener noreferrer">contact our team</a> with questions.</strong></em></p>
<h2 id="frequently-asked-questions-about-last-mile-china-routing">Frequently asked questions about last-mile China routing<a href="#frequently-asked-questions-about-last-mile-china-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="whats-the-difference-between-the-international-leg-and-the-last-mile">What’s the difference between the international leg and the last mile?<a href="#whats-the-difference-between-the-international-leg-and-the-last-mile" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>The international leg runs from your server to the point where traffic enters Chinese carrier infrastructure, and that’s what a premium route optimizes. The last mile covers the domestic hops from there to the user’s own connection, inside networks a foreign host has no relationship with.</p>
<h3 id="how-do-i-know-if-a-slowdown-is-happening-in-the-last-mile">How do I know if a slowdown is happening in the last mile?<a href="#how-do-i-know-if-a-slowdown-is-happening-in-the-last-mile" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Run a sustained MTR and look at where latency climbs or loss appears. If the international hops stay flat and the jump happens in the final few hops nearest the user, that localizes it to the domestic segment.</p>
<h3 id="can-a-mismatched-carrier-cause-what-looks-like-a-last-mile-problem">Can a mismatched carrier cause what looks like a last-mile problem?<a href="#can-a-mismatched-carrier-cause-what-looks-like-a-last-mile-problem" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes, and it’s the most common fixable cause. A route optimized for China Telecom serving a user on China Unicom will underperform even when both networks are healthy, because the premium path isn’t the one carrying that user’s traffic.</p>
<h3 id="does-peak-hour-timing-affect-last-mile-congestion">Does peak-hour timing affect last-mile congestion?<a href="#does-peak-hour-timing-affect-last-mile-congestion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Substantially. Domestic congestion in China varies a lot by time of day, so a test at 3am can look completely different from the same test during evening peak. Testing at multiple times is what separates a congestion pattern from a routing defect.</p>
<h3 id="is-there-anything-that-can-actually-be-done-about-last-mile-issues">Is there anything that can actually be done about last-mile issues?<a href="#is-there-anything-that-can-actually-be-done-about-last-mile-issues" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Nothing directly, since those hops sit inside Chinese domestic networks. What you can control is the international leg: confirming it runs on the premium path for your client’s specific carrier, and using a traceroute to localize where the problem actually sits before assuming it’s yours.</p>
<h3 id="should-i-just-tell-a-client-theres-nothing-we-can-do">Should I just tell a client there’s nothing we can do?<a href="#should-i-just-tell-a-client-theres-nothing-we-can-do" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>That’s accurate but incomplete, and it usually damages trust. Walk through the traceroute with them, be clear about the boundary, and then name the checks that are genuinely yours: carrier match, time-of-day pattern, and how many round trips your application needs per page load.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Last-Mile Routing" term="Last-Mile Routing"/>
        <category label="China Connectivity" term="China Connectivity"/>
        <category label="Client Communication" term="Client Communication"/>
        <category label="Network Troubleshooting" term="Network Troubleshooting"/>
        <category label="China Routing" term="China Routing"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Real-Name Registration vs. an ICP License: Which One Actually Applies to Your China-Facing Service?]]></title>
        <id>https://sa.net/blog/real-name-registration-vs-icp-license/</id>
        <link href="https://sa.net/blog/real-name-registration-vs-icp-license/"/>
        <updated>2026-09-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Real-name registration and ICP licensing get treated as one requirement, but they govern different things and attach to different parties. Here's what separates them, and which one a foreign-hosted server actually has to care about.]]></summary>
        <content type="html"><![CDATA[<p>Ask three people whether Chinese internet regulation applies to your foreign-hosted service and you’ll get three answers, usually because they’re each thinking of a different rule. Real-name registration and ICP licensing come up in the same conversations, often in the same sentence, as though they were one requirement with two names.</p>
<p>They aren’t. They govern different things, attach to different parties, and are triggered by different decisions. One is about verifying who your users are. The other is about whether you’re allowed to serve a site from inside mainland China at all.</p>
<p>Getting the distinction right matters because it changes the answer to the only question most businesses actually care about: does any of this reach a server in Tokyo or Singapore that happens to have Chinese visitors.</p>
<h2 id="the-short-version">The short version<a href="#the-short-version" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>









































<table><thead><tr><th></th><th>Real-name registration</th><th>ICP filing (备案)</th><th>Commercial ICP license (ICP证)</th></tr></thead><tbody><tr><td>What it governs</td><td>Verifying the real identity of users</td><td>Permission to serve a site from mainland infrastructure</td><td>Permission to run a revenue-generating online service</td></tr><tr><td>Who carries the obligation</td><td>The platform or operator signing up users</td><td>The site operator, gatekept by the hosting provider</td><td>The site operator</td></tr><tr><td>Entity required</td><td>Not applicable, it’s an operator duty</td><td>A mainland-registered entity; a wholly foreign-owned one qualifies</td><td>Generally a Chinese company, or a joint venture with no more than 50% foreign ownership</td></tr><tr><td>What triggers it</td><td>Operating a domestic service that has users</td><td>Hosting on mainland Chinese infrastructure</td><td>Charging users for online services</td></tr><tr><td>Reaches a foreign-hosted server?</td><td>Not directly</td><td>No, and you can’t obtain one</td><td>No</td></tr></tbody></table>
<h2 id="what-real-name-registration-actually-is">What real-name registration actually is<a href="#what-real-name-registration-actually-is" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Real-name registration is China’s requirement that users of domestic internet services be identified with verified real-world identity, typically a national ID number or a mobile number already tied to one. It covers mobile SIM cards, accounts on domestic platforms like WeChat and Weibo, apps distributed through Chinese app stores, and domestic hosting accounts.</p>
<p>It’s rooted in <a href="https://www.chinalawtranslate.com/en/cybersecuritylaw/" title="China’s Cybersecurity Law" target="_blank" rel="noopener noreferrer">China’s Cybersecurity Law</a>, whose Article 24 requires network operators to obtain real identity information when signing service agreements with users, and to refuse service where that information isn’t provided. Enforcement runs through the platforms and telecom carriers themselves, which are the parties held responsible.</p>
<p>The key structural point is that it’s an obligation on the operator of a domestic service, not a rule that attaches to users wherever they happen to browse. It has tightened steadily rather than loosened, so if you’re planning multi-year, assume more verification rather than less.</p>
<h2 id="what-icp-filing-and-an-icp-license-actually-are">What ICP filing and an ICP license actually are<a href="#what-icp-filing-and-an-icp-license-actually-are" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>ICP is where most of the confusion lives, partly because there are two distinct things sharing the abbreviation.</p>
<p>An ICP filing, usually called by its Chinese name beian, is the registration required for any site served from mainland Chinese infrastructure. It’s free, and it requires a mainland-registered entity, though a wholly foreign-owned enterprise generally qualifies. Enforcement is delegated to the hosting providers: a mainland host is required to verify your filing before serving your site, and to suspend sites that lack one.</p>
<p>A commercial ICP license, the ICP证, sits on top of that and is required when the service generates revenue online rather than simply publishing information. It involves substantive review rather than a registration formality, takes considerably longer, and carries an ownership constraint covered below that catches a lot of foreign businesses off guard.</p>
<p>The trigger for both is infrastructure, not audience. Filing is tied to hosting on mainland servers, which is why a site hosted in Tokyo, Singapore, or Hong Kong doesn’t need one. It’s worth being precise here: such a site isn’t merely exempt, it generally can’t obtain a filing at all, because the filing presupposes mainland hosting.</p>
<h2 id="how-the-two-connect">How the two connect<a href="#how-the-two-connect" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Here’s the relationship that makes the comparison useful rather than academic: the ICP path is what converts real-name obligations from something you inherit to something you carry.</p>
<p>A foreign-hosted business encounters real-name verification indirectly, through the platforms it builds on. Offer WeChat login and you’re consuming an identity system that already performed verification, bound by WeChat’s terms about what you do with the result. Use a China-based SMS gateway, accept Alipay or WeChat Pay, or distribute through a Chinese app store, and each of those relationships brings its own filing and identity expectations. Your compliance surface tracks your integrations, not your server location.</p>
<p>Take an ICP filing and a mainland entity, and the position changes in kind rather than degree. You’re now a domestic operator inside the regulatory perimeter, where the identity framework applies to you directly, alongside content responsibilities and ongoing filing duties that an offshore service never touches. That’s the real reason ICP is a strategic decision rather than a procedural step.</p>
<h2 id="the-ownership-constraint-that-decides-it-for-many-businesses">The ownership constraint that decides it for many businesses<a href="#the-ownership-constraint-that-decides-it-for-many-businesses" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The commercial ICP license carries a restriction that reframes the whole question for foreign companies: it’s generally limited to wholly Chinese companies or joint ventures with no more than 50% foreign ownership.</p>
<p>For a foreign-majority business, that means the commercial license usually isn’t a matter of budget or patience. It requires restructuring, typically a joint venture with a Chinese partner holding at least half the equity, which is a corporate decision rather than a compliance task.</p>
<p>This is why the practical comparison for most foreign businesses isn’t “real-name registration or ICP license.” It’s “operate offshore and reach China over optimized routing, or restructure the company to operate inside it.” Framed that way, the latency conversation that usually dominates these discussions turns out to be the smaller half of the decision.</p>
<h2 id="which-question-youre-actually-answering">Which question you’re actually answering<a href="#which-question-youre-actually-answering" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>If your infrastructure sits outside mainland China, neither ICP requirement applies to you, and real-name registration reaches you only through the domestic platforms you’ve chosen to integrate with. The <a href="https://sa.net/blog/what-is-china-optimized-transit/" title="hosting and routing layer" target="_blank" rel="noopener noreferrer">hosting and routing layer</a> is a network decision. A VPS in Tokyo or <a href="https://sa.net/blog/best-hosts-in-singapore-china-optimized-routing/" title="Singapore" target="_blank" rel="noopener noreferrer">Singapore</a> reaching mainland users over <a href="https://sa.net/network/" title="CTGNet, CUP, or CMIN2" target="_blank" rel="noopener noreferrer">CTGNet, CUP, or CMIN2</a> doesn’t make you a domestic operator, and choosing a premium carrier path doesn’t move you one step closer to the licensing regime.</p>
<p>One caveat worth raising with a partner rather than assuming: if you add mainland CDN or cloud capacity to an offshore deployment, you’ve introduced mainland infrastructure into the picture, and the filing question deserves a direct answer from that provider rather than an inference from your own setup.</p>
<p>Beyond that, the useful exercise is listing the domestic Chinese services you actually depend on, payment processors, login providers, SMS gateways, app store distribution, CDN partners, and getting advice on that list. “Does Chinese internet law apply to us” has no useful answer. “What do we take on by using this payment processor and this SMS gateway, with no Chinese entity” has a real one, and a China-focused advisor can usually give it quickly.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Real-name registration governs identity verification by operators of domestic services. ICP filing governs whether you may serve a site from mainland infrastructure at all, and the commercial license governs whether you may charge for it. They’re separate rules, and only the ICP path pulls a business into the perimeter where the identity rules apply to it directly.</p>
<p>For a foreign-hosted service, the honest summary is that neither applies to your server, and the exposure you do have arrives through the domestic platforms you’ve integrated with. If the ICP route is ever genuinely on the table, the ownership constraint on the commercial license is the detail to check first, because it decides the question more often than latency does.</p>
<p>Thanks for reading! Whether you’re weighing a mainland presence or planning to serve China from outside it, knowing which of these two rules actually applies to you is what keeps the decision straightforward. If you’re looking for China routing you can verify yourself rather than take on faith, Riven Cloud runs <a href="https://sa.net/pricing/" title="KVM VPS in Tokyo and Singapore" target="_blank" rel="noopener noreferrer">KVM VPS in Tokyo and Singapore</a> with all three named premium paths on every plan: CTGNet on China Telecom, CUP on China Unicom, and CMIN2 on China Mobile, each testable through a public <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> before you buy.</p>
<p><em><strong>Ready to see the numbers for your own traffic? <a href="https://portal.sa.net/store/cloud-server/" title="Deploy a server" target="_blank" rel="noopener noreferrer">Deploy a server</a> or <a href="https://sa.net/contact/" title="contact our team" target="_blank" rel="noopener noreferrer">contact our team</a> with questions.</strong></em></p>
<h2 id="frequently-asked-questions-about-real-name-registration-and-icp-licensing">Frequently asked questions about real-name registration and ICP licensing<a href="#frequently-asked-questions-about-real-name-registration-and-icp-licensing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="whats-the-core-difference-between-real-name-registration-and-an-icp-license">What’s the core difference between real-name registration and an ICP license?<a href="#whats-the-core-difference-between-real-name-registration-and-an-icp-license" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Real-name registration governs verifying the identity of users on a domestic service. ICP filing and licensing govern whether you’re permitted to serve a site from mainland infrastructure, and whether you may charge for it. Different obligations, different triggering conditions.</p>
<h3 id="does-my-foreign-hosted-server-need-an-icp-filing-to-reach-chinese-users">Does my foreign-hosted server need an ICP filing to reach Chinese users?<a href="#does-my-foreign-hosted-server-need-an-icp-filing-to-reach-chinese-users" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. The filing is tied to hosting on mainland Chinese infrastructure rather than to your audience, so a site hosted in Tokyo or Singapore doesn’t need one and generally can’t obtain one either.</p>
<h3 id="does-real-name-registration-apply-to-my-foreign-hosted-service-directly">Does real-name registration apply to my foreign-hosted service directly?<a href="#does-real-name-registration-apply-to-my-foreign-hosted-service-directly" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. It attaches to operators of domestic services under Chinese jurisdiction. Where it reaches a foreign business, it’s through the terms of domestic platforms you’ve integrated with, such as WeChat login or a China-based SMS gateway.</p>
<h3 id="whats-the-difference-between-an-icp-filing-and-a-commercial-icp-license">What’s the difference between an ICP filing and a commercial ICP license?<a href="#whats-the-difference-between-an-icp-filing-and-a-commercial-icp-license" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>The filing is a free registration required for any mainland-hosted site, and a wholly foreign-owned entity can generally obtain it. The commercial license is an additional approval for revenue-generating online services, involving substantive review and a much longer timeline.</p>
<h3 id="can-a-foreign-owned-company-get-a-commercial-icp-license">Can a foreign-owned company get a commercial ICP license?<a href="#can-a-foreign-owned-company-get-a-commercial-icp-license" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Usually not on its own. The commercial license is generally limited to Chinese companies or joint ventures where foreign ownership doesn’t exceed 50%, so it typically requires restructuring rather than just an application.</p>
<h3 id="does-adding-a-mainland-cdn-to-an-offshore-setup-change-anything">Does adding a mainland CDN to an offshore setup change anything?<a href="#does-adding-a-mainland-cdn-to-an-offshore-setup-change-anything" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Potentially, because you’ve introduced mainland infrastructure into the delivery path. It’s worth putting that question directly to the CDN provider rather than assuming your offshore hosting settles it.</p>
<h3 id="should-i-get-advice-on-my-specific-integrations">Should I get advice on my specific integrations?<a href="#should-i-get-advice-on-my-specific-integrations" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes, and it’s far more productive than the general question. List the domestic services you depend on, payment, login, SMS, app store, CDN, and ask about that list, since obligations vary by platform rather than applying uniformly.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Real-Name Registration" term="Real-Name Registration"/>
        <category label="ICP License" term="ICP License"/>
        <category label="ICP Filing" term="ICP Filing"/>
        <category label="China Compliance" term="China Compliance"/>
        <category label="China Internet Regulation" term="China Internet Regulation"/>
        <category label="Foreign Business in China" term="Foreign Business in China"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Is China's "Eastern Data and Western Computing" Initiative, and Does It Affect Foreign-Facing Hosting?]]></title>
        <id>https://sa.net/blog/what-is-eastern-data-western-computing/</id>
        <link href="https://sa.net/blog/what-is-eastern-data-western-computing/"/>
        <updated>2026-09-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[China has been steering new data center capacity toward its western provinces for years under a national initiative called Eastern Data and Western Computing. Here's what it actually does, and whether it changes anything for a foreign business routing traffic into China.]]></summary>
        <content type="html"><![CDATA[<p>If you’ve read anything about China’s domestic data infrastructure in the last few years, you’ve run into the phrase “Eastern Data and Western Computing,” sometimes written as “Eastern Data, Western Computing.” It sounds like something that should matter a great deal to anyone routing traffic into China.</p>
<p>It’s worth being direct about this one: for most foreign-hosted setups, it changes nothing about your route. But it does change something real about China’s domestic infrastructure, and there’s one specific scenario where that reaches you.</p>
<p>This piece covers what the initiative actually is, why Beijing launched it, and the narrow set of circumstances under which it becomes your problem rather than background reading.</p>
<h2 id="what-eastern-data-and-western-computing-actually-is">What Eastern Data and Western Computing actually is<a href="#what-eastern-data-and-western-computing-actually-is" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Eastern Data and Western Computing (东数西算) is a national Chinese strategy <a href="https://en.ndrc.gov.cn/news/mediarusources/202202/t20220218_1315947.html" title="approved in February 2022" target="_blank" rel="noopener noreferrer">approved in February 2022</a> by the National Development and Reform Commission and three other central departments. The premise is in the name: data generated in the developed eastern coastal regions gets processed in new data center capacity built in the western and inland provinces.</p>
<p>The build-out is organized into eight national computing hubs and ten data center clusters. Four of those hubs sit in the developed regions the name refers to as “east”: Beijing-Tianjin-Hebei, the Yangtze River Delta, the Guangdong-Hong Kong-Macao Greater Bay Area, and the Chengdu-Chongqing economic circle. The other four are the western ones: Inner Mongolia, Guizhou, Gansu, and Ningxia.</p>
<p>That split is the detail most summaries drop, and it changes how the policy should be read. This was never a plan to move all compute west; it’s a plan to keep latency-sensitive workloads near the eastern population centers while directing the growth in less time-critical processing, training runs, batch jobs, cold storage, toward cheaper western capacity.</p>
<p>The reasoning is practical rather than ideological. Land and electricity are cheaper in the west, hydro and wind generation capacity is more abundant there, and the eastern grid was under genuine strain from data center demand concentrated around major coastal cities. Moving compute west addresses a power problem and a regional development goal at the same time.</p>
<h2 id="what-this-does-and-what-it-doesnt">What this does, and what it doesn’t<a href="#what-this-does-and-what-it-doesnt" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>This is a policy about where China builds domestic compute and storage. It is not a policy about how international traffic enters China.</p>
<p>The things that actually determine your route quality sit entirely outside its scope: the international gateway points where foreign traffic crosses into Chinese networks, the peering arrangements the three state carriers maintain with networks abroad, and the specific premium paths like <a href="https://sa.net/network/" title="CTGNet, CUP, and CMIN2" target="_blank" rel="noopener noreferrer">CTGNet, CUP, and CMIN2</a> that traffic rides once it’s in. None of those change because a data center opened in Guizhou.</p>
<p>A server in Tokyo or Singapore reaching mainland China crosses the same borders on the same carrier infrastructure it did before the initiative started. If your setup is a foreign-hosted VPS serving Chinese users over a premium carrier route, this policy is context rather than a variable.</p>
<h2 id="where-its-actually-relevant-to-a-foreign-business">Where it’s actually relevant to a foreign business<a href="#where-its-actually-relevant-to-a-foreign-business" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>There’s one scenario where it genuinely reaches you, and it’s a common enough architecture to be worth naming: a hybrid setup where you pair foreign-hosted infrastructure with a domestic Chinese CDN or cloud partner handling caching or compute inside the country.</p>
<p>In that arrangement, where your partner’s infrastructure physically sits is now a live variable. A workload that used to run near Shanghai or Shenzhen might increasingly run out of a western hub instead, and that adds domestic distance between the compute and your users, who remain heavily concentrated in the eastern population centers. Your international route didn’t change, but the last leg of the journey did.</p>
<p>The practical response is simply to ask. Domestic providers can usually tell you which region serves a given workload, and if the answer is a western hub while your users are in Shanghai, that’s a conversation worth having about placement options rather than something to discover from a latency graph.</p>
<h2 id="why-this-comes-up-in-why-not-just-host-inside-china-conversations">Why this comes up in “why not just host inside China” conversations<a href="#why-this-comes-up-in-why-not-just-host-inside-china-conversations" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The other place this initiative shows up is in internal discussions where someone asks why the company doesn’t simply host inside mainland China, given how much capacity is clearly being built there.</p>
<p>The honest answer has little to do with capacity. Operating a public-facing service from inside mainland China requires an ICP license, which requires a Chinese business entity, which brings a set of ongoing regulatory obligations that a foreign company usually hasn’t budgeted for in either time or legal cost. That barrier is unchanged by Eastern Data and Western Computing, which is a domestic build-out program rather than a liberalization of who can operate infrastructure there.</p>
<p>So the initiative is useful evidence that China is investing heavily in domestic capacity, and it’s simply not evidence that the licensing path got easier. Those are separate questions, and conflating them tends to produce a plan that stalls at the legal review stage.</p>
<h2 id="does-this-change-anything-for-a-foreign-facing-server">Does this change anything for a foreign-facing server?<a href="#does-this-change-anything-for-a-foreign-facing-server" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For a straightforward foreign-hosted deployment, no. Your traffic enters China at the same gateways, on the same <a href="https://sa.net/blog/what-is-china-optimized-transit/" title="carrier paths" target="_blank" rel="noopener noreferrer">carrier paths</a>, with the same peak-hour congestion characteristics as before. The hub structure reinforces this: because the eastern hubs exist precisely to keep latency-sensitive workloads close to users, the capacity being built out west is largely the kind that was never going to sit in your request path anyway.</p>
<p>For a hybrid deployment with a domestic partner, possibly yes, and the way to find out is to ask that partner which hub serves your workload. Latency to that specific component will corroborate the answer, since a placement shift is measurable rather than theoretical, but asking is considerably faster than reverse-engineering it from a graph.</p>
<p>Either way, the part of the path you actually control, from your server to China’s border, is best verified the same way it always was. A public Looking Glass reading tells you what your route looks like right now, which is more useful than reasoning about policy documents.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Eastern Data and Western Computing is a real and significant shift in where China builds its own data infrastructure. It’s a domestic capacity story, not an international routing story, and treating it as the latter leads to worrying about the wrong variable.</p>
<p>For most foreign-facing businesses reaching China over a premium carrier path, this is context worth understanding and not a factor that changes your routing decisions. If you run a hybrid setup with a domestic partner, the one question worth asking is where their compute actually sits.</p>
<p>Thanks for reading! Whether you’re following how China’s data center map is shifting or simply routing traffic back to players and customers there, the carrier path matters more than which province a facility happens to sit in. If you’re looking for China routing you can verify yourself rather than take on faith, Riven Cloud runs <a href="https://sa.net/pricing/" title="KVM VPS in Tokyo and Singapore" target="_blank" rel="noopener noreferrer">KVM VPS in Tokyo and Singapore</a> with all three named premium paths on every plan: CTGNet on China Telecom, CUP on China Unicom, and CMIN2 on China Mobile, each testable through a public <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> before you buy.</p>
<p><em><strong>Ready to see the numbers for your own traffic? <a href="https://portal.sa.net/store/cloud-server/" title="Deploy a server" target="_blank" rel="noopener noreferrer">Deploy a server</a> or <a href="https://sa.net/contact/" title="contact our team" target="_blank" rel="noopener noreferrer">contact our team</a> with questions.</strong></em></p>
<h2 id="frequently-asked-questions-about-eastern-data-and-western-computing">Frequently asked questions about Eastern Data and Western Computing<a href="#frequently-asked-questions-about-eastern-data-and-western-computing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="does-eastern-data-and-western-computing-affect-how-fast-my-china-optimized-route-performs">Does Eastern Data and Western Computing affect how fast my China-optimized route performs?<a href="#does-eastern-data-and-western-computing-affect-how-fast-my-china-optimized-route-performs" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not directly. It governs where China builds domestic compute capacity, not the international carrier paths like CTGNet, CUP, or CMIN2 that a foreign-hosted server uses to reach Chinese users. Your route crosses the same gateways it always did.</p>
<h3 id="do-i-need-to-do-anything-differently-because-of-this-policy">Do I need to do anything differently because of this policy?<a href="#do-i-need-to-do-anything-differently-because-of-this-policy" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Only if you use a domestic Chinese CDN or cloud partner alongside your own infrastructure. In that case, confirm which regional hub serves your workload, since western placement adds domestic distance to users concentrated in eastern cities.</p>
<h3 id="is-this-related-to-chinas-great-firewall-or-internet-censorship">Is this related to China’s Great Firewall or internet censorship?<a href="#is-this-related-to-chinas-great-firewall-or-internet-censorship" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. It’s a data center and energy policy aimed at grid load and regional development. Content filtering and international gateway control are governed separately and weren’t changed by this initiative.</p>
<h3 id="why-did-china-launch-this-initiative-in-the-first-place">Why did China launch this initiative in the first place?<a href="#why-did-china-launch-this-initiative-in-the-first-place" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Primarily to relieve an eastern power grid under pressure from concentrated data center demand, and to use cheaper land and more abundant renewable generation in the west. Regional economic development in inland provinces was the other stated goal.</p>
<h3 id="does-this-mean-data-centers-in-eastern-chinese-cities-are-being-phased-out">Does this mean data centers in eastern Chinese cities are being phased out?<a href="#does-this-mean-data-centers-in-eastern-chinese-cities-are-being-phased-out" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. The policy directs new capacity growth westward rather than decommissioning existing eastern facilities, which continue to serve latency-sensitive workloads close to the population centers.</p>
<h3 id="can-a-foreign-company-use-one-of-these-new-western-hubs">Can a foreign company use one of these new western hubs?<a href="#can-a-foreign-company-use-one-of-these-new-western-hubs" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Renting capacity from a Chinese provider is possible, but operating a public-facing service from inside mainland China requires an ICP license and a Chinese business entity, and that requirement is the same regardless of which region the facility sits in. The new capacity doesn’t change the licensing path.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Eastern Data and Western Computing" term="Eastern Data and Western Computing"/>
        <category label="China Data Infrastructure" term="China Data Infrastructure"/>
        <category label="China Cloud Policy" term="China Cloud Policy"/>
        <category label="China Routing" term="China Routing"/>
        <category label="Data Center Policy" term="Data Center Policy"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why Some VPNs Make China Routing Worse, Not Better, for Business Traffic]]></title>
        <id>https://sa.net/blog/why-some-vpns-make-china-routing-worse/</id>
        <link href="https://sa.net/blog/why-some-vpns-make-china-routing-worse/"/>
        <updated>2026-09-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A VPN sounds like a reasonable fix for spotty China connectivity, but for business traffic it often makes latency and reliability worse, not better. Here's why, and when a VPN actually helps versus when it just adds another point of failure.]]></summary>
        <content type="html"><![CDATA[<p>When a service performs badly for users in China, “put it behind a VPN” comes up almost immediately. It’s the tool most people associate with China’s internet, so the suggestion feels natural.</p>
<p>For business traffic, it usually makes things worse. Not marginally worse either: adding a VPN hop to a China-facing production path frequently increases latency, adds packet loss, and introduces a failure mode that’s harder to diagnose than the problem it was meant to solve.</p>
<p>The reason comes down to a mismatch between what a VPN is built for and what a business needs, so it’s worth walking through what actually happens to your traffic.</p>
<h2 id="why-people-reach-for-a-vpn-in-the-first-place">Why people reach for a VPN in the first place<a href="#why-people-reach-for-a-vpn-in-the-first-place" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For an individual inside China trying to reach a blocked site, a VPN does something genuinely useful. It wraps traffic in an encrypted tunnel to a server outside the country, and the content filtering that would have blocked the destination can’t read what’s inside the tunnel.</p>
<p>That mental model gets carried into a different problem without much examination. Serving a service reliably to users inside China is not the same task as one person occasionally reaching a blocked website from inside China, and the tool that solves the second one isn’t designed for the first.</p>
<p>The distinction matters because the two use cases have opposite tolerances. A person circumventing a block will accept slow and unreliable, because slow beats blocked. A business serving thousands of users needs consistent and predictable, which is precisely what a VPN hop erodes.</p>
<h2 id="what-a-vpn-actually-does-to-your-route">What a VPN actually does to your route<a href="#what-a-vpn-actually-does-to-your-route" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Start with geometry. A VPN routes traffic through an intermediate server, which means the packet’s physical path gets longer, not shorter. If your server is in Tokyo, your user is in Shanghai, and the VPN endpoint is in Los Angeles, you’ve added a Pacific crossing in each direction to a route that didn’t need one.</p>
<p>Then there’s the quality of the infrastructure itself. General-purpose VPN providers aren’t buying premium carrier paths into China; they’re running on ordinary transit, often oversubscribed, because their product is measured on server count and price rather than on route quality to one specific country. Traffic that would otherwise ride a premium path like <a href="https://sa.net/network/" title="CTGNet, CUP, or CMIN2" target="_blank" rel="noopener noreferrer">CTGNet, CUP, or CMIN2</a> instead rides whatever generic transit the VPN provider happens to have.</p>
<p>The third factor is the most consequential, and it works against tunnels from two directions at once. Older, widely deployed VPN protocols have recognizable handshake patterns that filtering infrastructure can match directly. Newer protocols that avoid that by looking like pure random data run into the opposite test: published research on <a href="https://www.usenix.org/conference/usenixsecurity23/presentation/wu-mingshi" title="how the Great Firewall identifies fully encrypted traffic" target="_blank" rel="noopener noreferrer">how the Great Firewall identifies fully encrypted traffic</a> found it applies fairly crude heuristics that exempt traffic resembling a recognized protocol and act on much of what’s left, which is exactly where a fully encrypted tunnel sits. The result isn’t a clean tunnel that gets through; it’s a tunnel that intermittently gets throttled, degraded, or reset, specifically because it’s identifiable as a VPN. Your traffic ends up worse off than unremarkable traffic on a good route would have been.</p>
<h2 id="where-this-actually-bites-business-traffic">Where this actually bites business traffic<a href="#where-this-actually-bites-business-traffic" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The workloads that suffer most are the ones with no tolerance for variance. API calls with client-side timeouts, database replication that falls behind and then has to catch up, payment callbacks that expect a response inside a fixed window, and real-time features like chat or live updates all degrade visibly when the path underneath them is inconsistent.</p>
<p>It also produces a support problem. When a VPN hop is in the path, intermittent failures come with no clear signal about where they originated, so your team spends time investigating an application that’s working correctly. Removing an unnecessary tunnel often does more for diagnosability than any amount of additional monitoring.</p>
<p>There’s a compounding effect worth naming too. Because the VPN adds latency, every retransmission costs more than it would on a direct route, so the packet loss the VPN introduced is more expensive to recover from than the same loss would have been without it.</p>
<h2 id="when-a-vpn-does-make-sense">When a VPN does make sense<a href="#when-a-vpn-does-make-sense" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>None of this makes VPNs useless in a China context. It makes them the wrong tool for public service delivery.</p>
<p>A site-to-site VPN connecting your own offices or internal infrastructure is a reasonable use: the traffic volume is modest, the participants are known, and a private encrypted link between two of your own locations is exactly what the technology is for. <a href="https://sa.net/blog/self-host-rustdesk-china-remote-support/" title="Internal administrative access" target="_blank" rel="noopener noreferrer">Internal administrative access</a> for your own team falls into the same category, as does an emergency fallback path you keep available but don’t route production traffic through.</p>
<p>The dividing line is whether the traffic belongs to your users or to your own operations. Internal, low-volume, and security-motivated points toward a VPN being fine. Public, continuous, and latency-sensitive points away from it.</p>
<h2 id="what-actually-helps-instead">What actually helps instead<a href="#what-actually-helps-instead" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>For business traffic reaching mainland China, the thing that works is carrier-optimized routing that covers the carriers your users are actually on, which for any sizeable user base usually means all three rather than one. Keeping traffic on China Telecom’s, China Unicom’s, or China Mobile’s own premium backbone for a longer stretch of the path removes congested handoffs rather than adding an extra one.</p>
<p>Riven Cloud runs premium paths on <a href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/" title="all three" target="_blank" rel="noopener noreferrer">all three</a>: CTGNet on China Telecom (AS23764 and AS4809), CUP on China Unicom (AS9929 and AS10099), and CMIN2 on China Mobile (AS58807). Naming them matters because it’s what makes the claim checkable rather than aspirational.</p>
<p>The other half is verification. Every location has a public Looking Glass for ping, traceroute, and BGP lookups, so you can compare a real route against whatever you’re running today before changing anything. One caveat worth stating plainly: a Looking Glass reading reflects a single moment, not a guarantee of day-to-day performance, so test more than once and at more than one time of day.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A VPN’s strength is getting an individual around a block. It was never designed to deliver consistent, low-latency service to a user base, and in China specifically it’s actively penalized by the filtering it’s meant to evade.</p>
<p>If users in China are having a bad time with your service, the productive move is checking whether your server’s route to their carrier is any good, not wrapping a bad route in a tunnel and hoping the encryption compensates.</p>
<p>Thanks for reading! Whether you’re testing through a tunnel or serving users directly, the carrier path underneath matters more than the software layered on top of it. If you’re looking for China routing you can verify yourself rather than take on faith, Riven Cloud runs <a href="https://sa.net/pricing/" title="KVM VPS in Tokyo and Singapore" target="_blank" rel="noopener noreferrer">KVM VPS in Tokyo and Singapore</a> with all three named premium paths on every plan: CTGNet on China Telecom, CUP on China Unicom, and CMIN2 on China Mobile, each testable through a public <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> before you buy.</p>
<p><em><strong>Ready to see the numbers for your own traffic? <a href="https://portal.sa.net/store/cloud-server/" title="Deploy a server" target="_blank" rel="noopener noreferrer">Deploy a server</a> or <a href="https://sa.net/contact/" title="contact our team" target="_blank" rel="noopener noreferrer">contact our team</a> with questions.</strong></em></p>
<h2 id="frequently-asked-questions-about-vpns-and-china-routing">Frequently asked questions about VPNs and China routing<a href="#frequently-asked-questions-about-vpns-and-china-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="does-a-vpn-always-make-china-routing-worse">Does a VPN always make China routing worse?<a href="#does-a-vpn-always-make-china-routing-worse" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not in every configuration, but for business traffic serving users in China it usually does. You’re adding physical distance, riding generic transit instead of a premium carrier path, and running a protocol that border filtering specifically looks for.</p>
<h3 id="why-does-chinas-border-filtering-target-vpn-traffic-specifically">Why does China’s border filtering target VPN traffic specifically?<a href="#why-does-chinas-border-filtering-target-vpn-traffic-specifically" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Older VPN protocols carry recognizable handshakes that can be matched directly. Newer ones that look like random data get caught by the inverse test, since traffic resembling no known protocol is itself treated as suspect. Either way the tunnel stands out from ordinary traffic, even when what’s inside it is entirely unremarkable.</p>
<h3 id="is-a-business-vpn-different-from-a-consumer-vpn-in-this-context">Is a business VPN different from a consumer VPN in this context?<a href="#is-a-business-vpn-different-from-a-consumer-vpn-in-this-context" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>The underlying mechanics are the same. What differs is the expectation: a consumer tolerates instability because the alternative is no access, while a business serving users needs consistency that a tunneled path across an inspected border doesn’t reliably provide.</p>
<h3 id="what-should-i-use-instead-of-a-vpn-for-a-china-facing-service">What should I use instead of a VPN for a China-facing service?<a href="#what-should-i-use-instead-of-a-vpn-for-a-china-facing-service" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>A carrier-optimized route on a named premium path, matched to the carrier your users actually use. CTGNet, CUP, and CMIN2 exist precisely because each of the three Chinese carriers runs a separate network with its own congestion points.</p>
<h3 id="is-it-ever-appropriate-to-use-a-vpn-alongside-china-optimized-hosting">Is it ever appropriate to use a VPN alongside China-optimized hosting?<a href="#is-it-ever-appropriate-to-use-a-vpn-alongside-china-optimized-hosting" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes, for internal use. Connecting your own offices, giving your team administrative access, or keeping a fallback path available are all reasonable. The distinction is that these are your own low-volume operational needs rather than the path your users’ traffic takes.</p>
<h3 id="how-can-i-tell-if-a-vpn-is-actually-hurting-my-china-routes-performance">How can I tell if a VPN is actually hurting my China route’s performance?<a href="#how-can-i-tell-if-a-vpn-is-actually-hurting-my-china-routes-performance" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Run the same traceroute and sustained ping test with and without the tunnel, at similar times of day, and compare latency, jitter, and loss. If the tunneled path is meaningfully worse across those three, you have your answer without needing to theorize about why.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="VPN" term="VPN"/>
        <category label="Business VPN" term="Business VPN"/>
        <category label="China Connectivity" term="China Connectivity"/>
        <category label="Network Latency" term="Network Latency"/>
        <category label="VPN Performance" term="VPN Performance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[8 Best Hosts in Singapore with China-Optimized Routing]]></title>
        <id>https://sa.net/blog/best-hosts-in-singapore-china-optimized-routing/</id>
        <link href="https://sa.net/blog/best-hosts-in-singapore-china-optimized-routing/"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Singapore comes up constantly in China-facing hosting discussions, but being hosted there doesn't guarantee good China routing. Here are eight providers with a genuine Singapore presence, and what to verify before choosing one.]]></summary>
        <content type="html"><![CDATA[<p>Singapore shows up constantly in China-facing hosting discussions because of its position: close enough to mainland China for reasonable latency, outside China’s own regulatory jurisdiction, and home to serious international connectivity infrastructure. But “hosted in Singapore” and “actually has good China routing from Singapore” aren’t the same claim, and a fair number of providers blur that line. Here are eight providers with a genuine Singapore presence worth putting on your list, along with what to verify before committing to any of them.</p>
<h2 id="quick-comparison">Quick comparison<a href="#quick-comparison" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>



























































<table><thead><tr><th>Provider</th><th>Singapore presence</th><th>China-optimized routing</th><th>Best for</th></tr></thead><tbody><tr><td>Riven Cloud</td><td>Equinix SG1</td><td>Named CTGNet, CUP, and CMIN2 routes</td><td>Verifiable multi-carrier routing via a public Looking Glass</td></tr><tr><td>BandwagonHost</td><td>Equinix SG1</td><td>CN2 GIA on select plans</td><td>Budget-friendly, smaller-scale users</td></tr><tr><td>RackNerd</td><td>Confirmed data center</td><td>Varies by plan and promotion</td><td>Cost-conscious buyers</td></tr><tr><td>GigsGigsCloud</td><td>Tier III data center</td><td>Core focus, carrier-specific plans</td><td>Technical users wanting routing detail</td></tr><tr><td>Megalayer</td><td>Tier III data center (eastern Singapore)</td><td>China-optimized emphasis</td><td>Teams wanting VPS, dedicated, and colocation together</td></tr><tr><td>UCloud</td><td>Presence since 2016</td><td>China-optimized connectivity</td><td>Cloud-native features beyond simple VPS</td></tr><tr><td>Zenlayer</td><td>Regional network presence</td><td>Asia/China connectivity focus</td><td>Enterprise, larger-scale infrastructure</td></tr><tr><td>Alibaba Cloud International</td><td>Confirmed data center</td><td>Structurally different relationship to China</td><td>A distinct category, not a direct substitute</td></tr></tbody></table>
<h2 id="1-riven-cloud">1. Riven Cloud<a href="#1-riven-cloud" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/tmgnw/riven-cloud.webp" alt="Riven Cloud homepage" title="Riven Cloud homepage"/></p>
<p><a href="https://sa.net/" title="Riven Cloud" target="_blank" rel="noopener noreferrer">Riven Cloud</a> runs KVM VPS out of Equinix SG1 in Singapore with routing across three named carrier paths, CTGNet (China Telecom, formerly CN2 GIA), CUP (China Unicom), and CMIN2 (China Mobile), rather than a single blended “China-optimized” claim. Our <a href="https://singapore.lg.sa.net/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> is public, so you can test the exact route before committing to anything, and we’d rather tell you a location or route doesn’t fit your use case up front than have you find out after signing up.</p>
<p><em>Note: Most of the providers below are competitors to Riven Cloud in the China-optimized Singapore hosting market. We’ve made an intentional effort to represent them fairly. Pricing, specs, and route quality change over time, verify current details directly with each provider before deciding.</em></p>
<h2 id="2-bandwagonhost">2. BandwagonHost<a href="#2-bandwagonhost" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/9xpek/bandwagonhost.webp" alt="BandwagonHost homepage" title="BandwagonHost homepage"/></p>
<p><a href="https://bandwagonhost.com/" title="BandwagonHost" target="_blank" rel="noopener noreferrer">BandwagonHost</a>, operated by IT7 Networks, is a Canadian-headquartered provider with a long-standing presence in the China-optimized VPS space and a Singapore location in Equinix SG1 among its data center options. It’s built a loyal following particularly among smaller-scale users and developers, partly on the strength of budget-friendly plans with <a href="https://sa.net/blog/china-telecom-premium/" title="CN2 GIA routing" target="_blank" rel="noopener noreferrer">CN2 GIA routing</a> available on select locations.</p>
<p>As with any provider in this space, confirm which specific route (CTGNet, still sometimes marketed as CN2 GIA, versus standard transit) applies to the exact Singapore plan you’re looking at, since not every plan tier necessarily includes the premium route.</p>
<h2 id="3-racknerd">3. RackNerd<a href="#3-racknerd" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/qzms2/racknerd.webp" alt="RackNerd homepage" title="RackNerd homepage"/></p>
<p><a href="https://www.racknerd.com/" title="RackNerd" target="_blank" rel="noopener noreferrer">RackNerd</a> has built a reputation in budget VPS hosting more broadly, with a confirmed Singapore data center among its many location options across a wide global footprint. Its pricing is aggressive, often running frequent promotional deals, which makes it a common recommendation for cost-conscious buyers. Network quality and route optimization can vary more by specific promotion and plan tier than with more narrowly focused providers, so it’s worth checking the specific plan’s routing details rather than assuming uniform quality across RackNerd’s entire catalog.</p>
<h2 id="4-gigsgigscloud">4. GigsGigsCloud<a href="#4-gigsgigscloud" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/k2x87/gigsgigscloud.webp" alt="GigsGigsCloud homepage" title="GigsGigsCloud homepage"/></p>
<p><a href="https://www.gigsgigscloud.com/" title="GigsGigsCloud" target="_blank" rel="noopener noreferrer">GigsGigsCloud</a> operates a Tier III data center presence in Singapore and has built a specific reputation around China-optimized routing as a core focus rather than a secondary feature, which shows in how deliberately its plans are marketed around specific carrier routes. It tends to skew toward a more technical audience comfortable evaluating routing details directly rather than a general consumer hosting audience.</p>
<h2 id="5-megalayer">5. Megalayer<a href="#5-megalayer" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/bqk9t/megalayer.webp" alt="Megalayer homepage" title="Megalayer homepage"/></p>
<p><a href="https://www.megalayer.net/" title="Megalayer" target="_blank" rel="noopener noreferrer">Megalayer</a> maintains a Tier III data center presence in eastern Singapore, with a product lineup that spans VPS, dedicated servers, and colocation, alongside a specific emphasis on China-optimized connectivity. Its broader product range makes it a reasonable option if you expect to need more than a simple VPS down the line, dedicated hardware or colocation, without switching to an entirely different provider for those services.</p>
<h2 id="6-ucloud">6. UCloud<a href="#6-ucloud" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/m2wnn/ucloud.webp" alt="UCloud homepage" title="UCloud homepage"/></p>
<p><a href="https://www.ucloud-global.com/en" title="UCloud" target="_blank" rel="noopener noreferrer">UCloud</a> has maintained a Singapore presence since 2016 and operates at a larger scale than most of the other providers on this list, with a broader cloud infrastructure product line beyond simple VPS hosting. It’s a fit for teams that want more cloud-native features (like managed databases or object storage) alongside China-optimized connectivity, rather than a straightforward, no-frills VPS.</p>
<h2 id="7-zenlayer">7. Zenlayer<a href="#7-zenlayer" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/pmxnu/zenlayer.webp" alt="Zenlayer homepage" title="Zenlayer homepage"/></p>
<p><a href="https://www.zenlayer.com/" title="Zenlayer" target="_blank" rel="noopener noreferrer">Zenlayer</a> operates infrastructure across Asia with a specific focus on connectivity into and out of China, including a Singapore presence as part of its broader regional network. It leans more toward enterprise and larger-scale infrastructure customers than individual developers or small projects, with product offerings that go well beyond a simple VPS, including dedicated connectivity and edge infrastructure services.</p>
<h2 id="8-alibaba-cloud-international">8. Alibaba Cloud International<a href="#8-alibaba-cloud-international" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><img src="https://cdn.xtom.com/2026/08/22/zywp7/alibaba-cloud.webp" alt="Alibaba Cloud International homepage" title="Alibaba Cloud International homepage"/></p>
<p><a href="https://www.alibabacloud.com/" title="Alibaba Cloud" target="_blank" rel="noopener noreferrer">Alibaba Cloud</a>’s international arm operates data centers in Singapore as part of its global infrastructure, and as a subsidiary of a Chinese parent company, it has a structurally different relationship with China-based connectivity than the other providers on this list, worth understanding as a distinct category rather than a direct substitute. It’s a heavier, more enterprise-oriented platform than the more narrowly focused providers above, with a correspondingly steeper learning curve and broader (and more complex) product catalog.</p>
<h2 id="what-to-verify-before-choosing-any-of-these">What to verify before choosing any of these<a href="#what-to-verify-before-choosing-any-of-these" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><strong>Confirm the specific route on the specific plan.</strong> “China-optimized” on a company’s homepage doesn’t guarantee every plan tier includes it. Ask which specific carrier route (<a href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/" title="CTGNet, CUP, CMIN2" target="_blank" rel="noopener noreferrer">CTGNet, CUP, CMIN2</a>, or a generic “CN2 GIA” claim worth double-checking per our <a href="https://sa.net/blog/questions-ctgnet-cn2-gia-claims/" title="earlier guide" target="_blank" rel="noopener noreferrer">earlier guide</a>) applies to the exact plan you’re considering.</p>
<p><strong>Test before committing beyond a trial period.</strong> A traceroute or a public looking glass tool tells you far more than a marketing page. If a provider won’t give you a test IP or trial window, that’s worth factoring into the decision itself.</p>
<p><strong>Check current pricing and specs directly.</strong> Hosting pricing and available specs shift often, and any comparison written at a point in time can go stale. Confirm current details on each provider’s own site before deciding.</p>
<p><strong>Ask about peak-hour performance</strong>, not just off-peak test results, since China-optimized routes can behave differently under real business-hour load than in a quiet 3 a.m. test.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Singapore’s combination of geographic proximity to China and jurisdiction outside it makes it a genuinely strong location for China-facing infrastructure, and each of the eight providers above has a real, verifiable Singapore presence worth evaluating on its own merits. The right pick depends on your specific carrier-route needs, your comfort with a more technical versus more managed platform, and your own testing, not just whichever name shows up most often in a forum thread.</p>
<p>Want to see how a real China-optimized route performs from Singapore before you commit anywhere? Riven Cloud’s <a href="https://singapore.lg.sa.net/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> lets you test CTGNet, CUP, and CMIN2 routing from our Singapore location (Equinix SG1) directly. If Riven Cloud ends up being the fit, our <a href="https://portal.sa.net/store/cloud-server/premium" title="Premium plan" target="_blank" rel="noopener noreferrer">Premium plan</a> starts at $49/mo with full root access on either Tokyo or Singapore.</p>
<h2 id="frequently-asked-questions-about-singapore-hosting-for-china-routing">Frequently asked questions about Singapore hosting for China routing<a href="#frequently-asked-questions-about-singapore-hosting-for-china-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="is-singapore-always-better-than-hong-kong-or-tokyo-for-china-facing-hosting">Is Singapore always better than Hong Kong or Tokyo for China-facing hosting?<a href="#is-singapore-always-better-than-hong-kong-or-tokyo-for-china-facing-hosting" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not universally. Each location has different strengths depending on carrier routing, jurisdiction preferences, and your specific audience’s location within China. Singapore’s advantage is largely its combination of solid connectivity and being outside Chinese regulatory jurisdiction, not a blanket performance win over every alternative.</p>
<h3 id="do-all-these-providers-offer-the-same-quality-of-china-routing">Do all these providers offer the same quality of China routing?<a href="#do-all-these-providers-offer-the-same-quality-of-china-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. Even among providers with genuine Singapore data centers, the specific carrier routes offered, and how consistently they perform, vary meaningfully. Always verify the specific route on the specific plan you’re considering.</p>
<h3 id="how-do-i-test-china-routing-quality-before-committing-to-a-plan">How do I test China routing quality before committing to a plan?<a href="#how-do-i-test-china-routing-quality-before-committing-to-a-plan" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Ask for a trial period or test IP, and run a traceroute from (or to) a Chinese IP address, or check the provider’s own public looking glass tool if they offer one. Testing at different times of day gives a more complete picture than a single test.</p>
<h3 id="why-isnt-dmit-on-this-list">Why isn’t DMIT on this list?<a href="#why-isnt-dmit-on-this-list" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>DMIT is a well-known name in the China-optimized VPS space, but as of this writing, its data center locations are Los Angeles, Hong Kong, and Tokyo, not Singapore, which is why it’s not included in a Singapore-specific comparison.</p>
<h3 id="does-a-bigger-provider-always-mean-better-china-routing">Does a bigger provider always mean better China routing?<a href="#does-a-bigger-provider-always-mean-better-china-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not necessarily. Scale can bring more infrastructure investment, but some smaller, more narrowly focused providers built their entire business around China-optimized routing specifically, which sometimes translates to more attention on that one thing than a larger, broader-focus provider gives it.</p>
<h3 id="should-pricing-be-the-main-factor-in-choosing-between-these-providers">Should pricing be the main factor in choosing between these providers?<a href="#should-pricing-be-the-main-factor-in-choosing-between-these-providers" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Pricing matters, but for China-facing infrastructure specifically, route quality and consistency usually matter more, since a cheaper plan with poor peak-hour routing performance can cost more in lost user experience than it saves in monthly fees.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="Singapore Hosting" term="Singapore Hosting"/>
        <category label="China-Optimized Routing" term="China-Optimized Routing"/>
        <category label="Singapore VPS" term="Singapore VPS"/>
        <category label="CN2 GIA Singapore" term="CN2 GIA Singapore"/>
        <category label="Hosting Comparison" term="Hosting Comparison"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[CTGNet (CN2 GIA) vs. CUP vs. CMIN2: A Quick Comparison for China Routing]]></title>
        <id>https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/</id>
        <link href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[CTGNet (still widely marketed as CN2 GIA), CUP, and CMIN2 get used interchangeably in hosting marketing, but they're not all the same thing. Here's what each one actually is, which carrier runs it, and when it matters which one you pick.]]></summary>
        <content type="html"><![CDATA[<p>Three carriers, and a lot of overlapping marketing language, including two different names for the same China Telecom product. If you’ve been comparing China-optimized hosting and keep running into CTGNet, CN2 GIA, CUP, and CMIN2 without a clear sense of what separates them, this is the reference to bookmark. No fluff, just what each one is, which carrier runs it, and when it actually matters which one you pick.</p>
<h2 id="the-short-version">The short version<a href="#the-short-version" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>





























<table><thead><tr><th>Route</th><th>Carrier</th><th>AS Numbers</th><th>Best for</th></tr></thead><tbody><tr><td>CTGNet (formerly CN2 GIA)</td><td>China Telecom</td><td>AS23764, AS4809</td><td>General China Telecom traffic, broad reach</td></tr><tr><td>CUP</td><td>China Unicom</td><td>AS9929, AS10099</td><td>Users primarily on China Unicom networks</td></tr><tr><td>CMIN2</td><td>China Mobile</td><td>AS58807</td><td>Users primarily on China Mobile networks, including mobile-heavy traffic</td></tr></tbody></table>
<h2 id="what-each-one-actually-is">What each one actually is<a href="#what-each-one-actually-is" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p><strong>CTGNet (formerly CN2 GIA).</strong> <a href="https://sa.net/blog/china-telecom-premium/" title="CTGNet" target="_blank" rel="noopener noreferrer">CTGNet</a> is the more current name China Telecom uses for its premium route, historically marketed as CN2 GIA, and you’ll still see both terms used, sometimes even by the same provider in different places. It’s built to keep traffic on China Telecom’s backbone for a longer stretch of the route than standard transit, avoiding the heaviest congestion points closer to typical international peering locations.</p>
<p>This matters specifically for users on China Telecom’s own network, which is the largest of the three major Chinese carriers and covers a broad geographic footprint across the country.</p>
<p><strong>CUP (China Unicom Premium).</strong> <a href="https://sa.net/blog/china-unicom-premium/" title="CUP" target="_blank" rel="noopener noreferrer">CUP</a> is China Unicom’s equivalent premium routing product, built on the same general principle, minimize hops and congestion for traffic destined to Unicom’s network specifically. If your audience or team is concentrated on China Unicom connections rather than China Telecom, a route optimized for CUP will generally outperform a CTGNet route for those specific users, even though CTGNet (still often called CN2 GIA) gets more attention in general hosting discussions.</p>
<p><strong>CMIN2 (China Mobile International N2).</strong> <a href="https://sa.net/blog/china-mobile-premium/" title="CMIN2" target="_blank" rel="noopener noreferrer">CMIN2</a> is China Mobile’s premium international route, and the newest of the three to become widely available in commercial hosting products. China Mobile has historically been more associated with mobile connectivity than fixed broadband, but its international routing product matters for any service with a meaningful share of China Mobile users, mobile or fixed.</p>
<h2 id="why-three-separate-routes-exist-at-all">Why three separate routes exist at all<a href="#why-three-separate-routes-exist-at-all" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>China’s internet infrastructure is split across three major state-affiliated carriers, China Telecom, China Unicom, and China Mobile, each operating largely separate physical networks with their own international gateways and peering arrangements. A route optimized for one carrier’s network doesn’t automatically carry the same benefit for traffic destined to a different carrier’s users, because the physical path and peering points differ.</p>
<p>This is the entire reason multiple “China-optimized” products exist instead of just one: a single premium route can’t be equally optimized for all three carriers simultaneously, since they’re genuinely separate networks with separate congestion points.</p>
<h2 id="does-it-matter-which-one-you-pick">Does it matter which one you pick?<a href="#does-it-matter-which-one-you-pick" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>It depends entirely on who’s actually accessing your service. If you don’t know your audience’s carrier distribution, the honest answer is that a single “China-optimized” route, whichever one you pick, is a meaningful improvement over standard international transit for a general China-facing audience, but it won’t be equally optimal for every visitor.</p>
<p>If you do know, or can find out, which carrier your specific audience is concentrated on, matching your route to that carrier delivers a bigger improvement than picking whichever route happens to be best-marketed.</p>
<p>Some providers, Riven Cloud included, offer routing across multiple carrier paths specifically because real audiences are rarely on a single carrier exclusively, and covering more than one route reduces the odds that a meaningful chunk of your traffic gets routed sub-optimally.</p>
<h2 id="how-to-figure-out-your-own-audiences-carrier-distribution">How to figure out your own audience’s carrier distribution<a href="#how-to-figure-out-your-own-audiences-carrier-distribution" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>If you already have traffic data, checking the AS number associated with visiting IP addresses can tell you roughly what share of your China-based visitors are on Telecom, Unicom, or Mobile networks respectively. This isn’t always precise, since some IP ranges are harder to attribute cleanly, but it’s usually enough to identify whether your traffic skews heavily toward one carrier or is genuinely mixed across all three.</p>
<h2 id="a-note-on-naming-confusion">A note on naming confusion<a href="#a-note-on-naming-confusion" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Because CTGNet and CN2 GIA refer to the same underlying China Telecom product, and because providers vary in which name they use, don’t assume two providers advertising different names are offering genuinely different routes without checking. <a href="https://sa.net/blog/questions-ctgnet-cn2-gia-claims/" title="Ask directly which carrier and AS numbers are involved" target="_blank" rel="noopener noreferrer">Ask directly which carrier and AS numbers are involved</a>, the answer matters more than which of the two interchangeable names shows up on the pricing page.</p>
<p>We list our own routing this way specifically to avoid this kind of ambiguity: Riven Cloud offers CTGNet (China Telecom, AS23764/AS4809), CUP (China Unicom, AS9929/AS10099), and CMIN2 (China Mobile, AS58807) as distinct, named routes rather than a single blended “China-optimized” claim, and our <a href="https://sa.net/network/" title="network page" target="_blank" rel="noopener noreferrer">network page</a> lists the exact paths for each.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>There’s no single “best” route among these three, only a best fit for a specific audience’s carrier distribution. If you’re evaluating providers, ask which specific carrier routes they offer, not just whether they have “China optimization” in general, and if you can determine your own audience’s carrier split, use that to guide which route actually matters most for your traffic.</p>
<p>Want to see the exact paths behind each route? Riven Cloud’s <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> lets you test CTGNet, CUP, and CMIN2 independently, from Tokyo or Singapore, before you commit to anything. Once you’ve found the route that fits, Riven Cloud’s <a href="https://portal.sa.net/store/cloud-server/premium" title="Premium plan" target="_blank" rel="noopener noreferrer">Premium plan</a> starts at $49/mo with that same routing built into every plan.</p>
<h2 id="frequently-asked-questions-about-china-carrier-routing">Frequently asked questions about China carrier routing<a href="#frequently-asked-questions-about-china-carrier-routing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="are-ctgnet-and-cn2-gia-actually-different-products">Are CTGNet and CN2 GIA actually different products?<a href="#are-ctgnet-and-cn2-gia-actually-different-products" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No, they refer to the same underlying China Telecom premium route. CTGNet is the more current name for what was historically marketed as CN2 GIA, and both terms are still in circulation.</p>
<h3 id="which-route-is-best-if-i-dont-know-my-audiences-carrier-distribution">Which route is best if I don’t know my audience’s carrier distribution?<a href="#which-route-is-best-if-i-dont-know-my-audiences-carrier-distribution" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Any of the three premium routes is a meaningful improvement over standard international transit for a general China-facing audience, but for the biggest improvement, look for a provider offering multiple carrier routes rather than betting on just one.</p>
<h3 id="can-i-check-which-carrier-my-visitors-are-actually-on">Can I check which carrier my visitors are actually on?<a href="#can-i-check-which-carrier-my-visitors-are-actually-on" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes, roughly. Checking the AS number associated with visitor IP addresses can indicate whether traffic is concentrated on Telecom, Unicom, or Mobile, though attribution isn’t always perfectly precise for every IP range.</p>
<h3 id="is-china-mobiles-cmin2-route-as-mature-as-ctgnet">Is China Mobile’s CMIN2 route as mature as CTGNet?<a href="#is-china-mobiles-cmin2-route-as-mature-as-ctgnet" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>It’s newer to widespread commercial availability than China Telecom’s product, but it serves the same fundamental purpose for China Mobile’s network specifically, and it’s become a standard offering among providers focused on China-optimized routing.</p>
<h3 id="do-i-need-all-three-routes-or-is-one-enough">Do I need all three routes, or is one enough?<a href="#do-i-need-all-three-routes-or-is-one-enough" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>It depends on your audience. If your traffic is genuinely concentrated on one carrier, one well-matched route may be sufficient. If your audience is mixed across carriers, coverage across multiple routes reduces the chance that a meaningful share of visitors gets a sub-optimal path.</p>
<h3 id="does-a-provider-need-separate-infrastructure-for-each-carrier-route">Does a provider need separate infrastructure for each carrier route?<a href="#does-a-provider-need-separate-infrastructure-for-each-carrier-route" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Generally yes, since each route involves separate peering arrangements and paths to a different carrier’s network. This is part of why not every provider offers all three, and why it’s worth asking specifically which ones a given provider actually supports.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="CTGNet" term="CTGNet"/>
        <category label="CN2 GIA" term="CN2 GIA"/>
        <category label="CUP" term="CUP"/>
        <category label="CMIN2" term="CMIN2"/>
        <category label="China Routing" term="China Routing"/>
        <category label="Carrier Comparison" term="Carrier Comparison"/>
        <category label="ASN" term="ASN"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[6 Questions to Ask Before Trusting a Provider's CTGNet (CN2 GIA) Claims]]></title>
        <id>https://sa.net/blog/questions-ctgnet-cn2-gia-claims/</id>
        <link href="https://sa.net/blog/questions-ctgnet-cn2-gia-claims/"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[CTGNet, still widely marketed under its former name CN2 GIA, gets printed on plenty of hosting pages that don't actually deliver it. Here are six questions worth asking any provider making that claim, and how to verify the answers yourself.]]></summary>
        <content type="html"><![CDATA[<p>CN2 GIA, now known as CTGNet, gets printed on a lot of hosting pages that don’t actually deliver it. Some providers genuinely have it. Some have regular CN2, which is a different, lower-tier product. Some have neither and are using the term loosely because it sells. Before you commit to a plan based on that label, ask these six questions and check the answers yourself rather than taking the pricing page at its word.</p>
<h2 id="1-is-it-ctgnet-cn2-gia-or-just-cn2">1. Is it CTGNet (CN2 GIA), or just CN2?<a href="#1-is-it-ctgnet-cn2-gia-or-just-cn2" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>These aren’t interchangeable. Regular CN2 is China Telecom’s improved network product, better than standard international transit, but it still typically exits through the same congested peering points as ordinary traffic once it reaches certain points in the path. <a href="https://sa.net/blog/china-telecom-premium/" title="CTGNet" target="_blank" rel="noopener noreferrer">CTGNet</a>, China Telecom’s current name for what was formerly marketed as CN2 GIA (Global Internet Access), is the premium tier, engineered to stay on China Telecom’s backbone for a longer portion of the route and avoid the worst congestion points entirely.</p>
<p>A provider using “CN2” and “CN2 GIA” interchangeably in its own marketing is a signal to look closer, not a confirmation that you’re getting the premium product.</p>
<p><strong>How to check:</strong> ask directly which one you’re getting, and ask for the <a href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/" title="AS numbers involved in the route" target="_blank" rel="noopener noreferrer">AS numbers involved in the route</a>. CTGNet routes commonly traverse AS4809 (China Telecom Next Generation Carrier Network, the network CTGNet actually runs on) at some point in the path. A route that never touches AS4809 and only shows the standard CN2 backbone AS number is regular CN2, not the premium tier, no matter what the plan is named.</p>
<h2 id="2-what-does-a-traceroute-from-a-real-test-ip-actually-show">2. What does a traceroute from a real test IP actually show?<a href="#2-what-does-a-traceroute-from-a-real-test-ip-actually-show" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>This is the single most useful verification step, and it costs you nothing. A legitimate CN2 GIA route has a specific, recognizable signature: it should stay on China Telecom’s own network for most of the path rather than handing off to a different backbone early, and it should show noticeably fewer hops and lower latency than a standard international route to the same region.</p>
<p><strong>How to check:</strong> ask for a test IP before you commit to anything beyond a trial period, and run a traceroute from a Chinese IP or ask the provider to share results from their own looking glass tool. Compare the hop count and latency against what CN2 GIA is known to deliver for that specific route (Shanghai and Beijing routes, for instance, tend to be the best-documented and most consistently good, since they’re the routes providers optimize for and demonstrate first).</p>
<h2 id="3-does-the-route-cover-the-specific-region-you-actually-care-about">3. Does the route cover the specific region you actually care about?<a href="#3-does-the-route-cover-the-specific-region-you-actually-care-about" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>A provider might have genuinely good CTGNet performance to Shanghai and terrible performance to a smaller inland province, because the physical route quality depends on where China Telecom’s backbone actually reaches well versus where it doesn’t. “CN2 GIA available” as a blanket claim doesn’t guarantee uniform performance across every region in China.</p>
<p><strong>How to check:</strong> if you know where your specific audience or team is concentrated, ask for a test to that specific location, not just a generic “China” test. A provider confident in their routing will usually accommodate this, and a lot of hesitation around a specific-region test request is itself informative.</p>
<h2 id="4-what-happens-during-peak-hours">4. What happens during peak hours?<a href="#4-what-happens-during-peak-hours" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>CTGNet routes are genuinely good most of the time, but “genuinely good” and “immune to congestion” aren’t the same thing. Some providers oversell capacity on their CTGNet allocation, and a route that performs beautifully at 3 a.m. can degrade noticeably during Chinese business hours when overall demand on that pipe is highest.</p>
<p><strong>How to check:</strong> if you’re able to run a trial, test at different times of day, including what would be peak business hours in China, not just whenever happens to be convenient for you. A single good test result from an off-peak hour tells you less than a pattern of results across a full day.</p>
<h2 id="5-is-this-route-reserved-for-their-own-infrastructure-or-resold-from-someone-else">5. Is this route reserved for their own infrastructure, or resold from someone else?<a href="#5-is-this-route-reserved-for-their-own-infrastructure-or-resold-from-someone-else" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Some providers buy wholesale CN2 GIA capacity from a larger upstream network and resell it, which isn’t inherently a problem, but it does mean the quality and consistency you get depends on a contract you can’t see and a relationship you’re not party to. If the reseller’s upstream provider has a bad month, changes terms, or gets deprioritized, your service quality can shift without warning and without the reseller necessarily being forthcoming about the cause.</p>
<p><strong>How to check:</strong> ask directly whether the provider owns the network infrastructure delivering the route or resells it from another network. Neither answer is automatically disqualifying, but a direct answer, and how readily it’s given, tells you something about how transparent the provider is likely to be later if something changes.</p>
<h2 id="6-does-the-provider-tell-you-what-they-dont-offer">6. Does the provider tell you what they don’t offer?<a href="#6-does-the-provider-tell-you-what-they-dont-offer" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>This is less a technical check and more a trust signal, but it’s a meaningful one. A provider willing to say plainly which locations or routes don’t get CTGNet treatment, rather than implying universal coverage through vague marketing language, is more likely to be giving you an accurate picture of the part they are confident about too.</p>
<p><strong>How to check:</strong> look for specificity in how the provider describes their own network. Marketing copy that names exact routes, specific AS numbers, and explicit carve-outs for what isn’t covered is a different kind of claim than a page that just says “CN2 GIA available” without further detail.</p>
<h2 id="putting-it-together">Putting it together<a href="#putting-it-together" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>None of these checks require special tools or technical expertise beyond running a traceroute, which most operating systems support natively, and asking direct questions a legitimate provider should be able to answer without hedging. The pattern to watch for isn’t one bad answer, it’s a provider that’s vague across several of these questions at once, since a network actually built around GIA transit usually has this information readily available and no particular reason to obscure it.</p>
<p>We don’t take a stance on which specific competitors do or don’t meet this bar, that changes over time and is worth checking for yourself, but the six questions above are the right ones to ask regardless of who you’re evaluating.</p>
<p>Ready to see it for yourself? Riven Cloud’s <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> is public, and our <a href="https://sa.net/network/" title="network page" target="_blank" rel="noopener noreferrer">network page</a> lists the exact AS numbers and carrier paths behind each of our three China-optimized routes. Run your own traceroute before you commit to anything, from us or from anyone else. When you’re ready to move forward, Riven Cloud’s <a href="https://portal.sa.net/store/cloud-server/premium" title="Premium plan" target="_blank" rel="noopener noreferrer">Premium plan</a> starts at $49/mo with the same routing available on every tier.</p>
<h2 id="frequently-asked-questions-about-verifying-ctgnet-cn2-gia-claims">Frequently asked questions about verifying CTGNet (CN2 GIA) claims<a href="#frequently-asked-questions-about-verifying-ctgnet-cn2-gia-claims" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="whats-the-fastest-way-to-tell-if-a-provider-actually-has-ctgnet-cn2-gia">What’s the fastest way to tell if a provider actually has CTGNet (CN2 GIA)?<a href="#whats-the-fastest-way-to-tell-if-a-provider-actually-has-ctgnet-cn2-gia" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>A traceroute from (or to) a Chinese IP address is the quickest real check. Look for the route staying on China Telecom’s backbone for most of the path and check whether AS4809 appears in it, rather than handing off to a different network early.</p>
<h3 id="is-regular-cn2-bad">Is regular CN2 bad?<a href="#is-regular-cn2-bad" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No, it’s a genuine improvement over standard international transit, just not as consistently optimized as CTGNet specifically. The problem isn’t regular CN2 itself, it’s providers marketing it using CTGNet’s reputation (still often called CN2 GIA) without actually delivering the premium tier.</p>
<h3 id="can-ctgnet-cn2-gia-performance-vary-by-time-of-day">Can CTGNet (CN2 GIA) performance vary by time of day?<a href="#can-ctgnet-cn2-gia-performance-vary-by-time-of-day" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes. Some providers oversell their CTGNet capacity, which can show up as degraded performance during peak hours even when off-peak tests look great. Testing at multiple times of day gives a more honest picture than a single test.</p>
<h3 id="does-it-matter-if-a-provider-resells-their-ctgnet-cn2-gia-capacity-instead-of-owning-it">Does it matter if a provider resells their CTGNet (CN2 GIA) capacity instead of owning it?<a href="#does-it-matter-if-a-provider-resells-their-ctgnet-cn2-gia-capacity-instead-of-owning-it" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not automatically, but it adds a layer of dependency you can’t directly verify. A reseller’s service quality depends on a contract and relationship you’re not party to, so it’s worth knowing which situation you’re in.</p>
<h3 id="should-i-trust-a-providers-own-looking-glass-results-completely">Should I trust a provider’s own looking glass results completely?<a href="#should-i-trust-a-providers-own-looking-glass-results-completely" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Treat them as a useful data point, not the final word. A provider’s own looking glass shows performance from their network’s perspective, which is informative but worth cross-checking with your own independent traceroute if the decision matters enough.</p>
<h3 id="does-good-ctgnet-cn2-gia-performance-to-shanghai-mean-itll-be-good-everywhere-in-china">Does good CTGNet (CN2 GIA) performance to Shanghai mean it’ll be good everywhere in China?<a href="#does-good-ctgnet-cn2-gia-performance-to-shanghai-mean-itll-be-good-everywhere-in-china" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Not necessarily. Route quality can vary meaningfully by region within China, so if your audience is concentrated somewhere specific, it’s worth testing that exact region rather than assuming a generic “China” result applies uniformly.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="CTGNet" term="CTGNet"/>
        <category label="CN2 GIA" term="CN2 GIA"/>
        <category label="China Routing" term="China Routing"/>
        <category label="China VPS" term="China VPS"/>
        <category label="Network Verification" term="Network Verification"/>
        <category label="Looking Glass" term="Looking Glass"/>
        <category label="BGP" term="BGP"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What Is "China-Optimized" Transit?]]></title>
        <id>https://sa.net/blog/what-is-china-optimized-transit/</id>
        <link href="https://sa.net/blog/what-is-china-optimized-transit/"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA["China-optimized" gets used loosely enough in hosting marketing that it's worth pinning down what it actually means, carrier by carrier. Here's what China Telecom, China Unicom, and China Mobile's own premium routes actually are, why standard transit performs poorly for China-facing traffic, and what the optimized versions actually buy you.]]></summary>
        <content type="html"><![CDATA[<p>“China-optimized” gets used loosely enough in hosting marketing that it’s worth pinning down what it’s actually supposed to mean, mechanically, before you evaluate whether a specific provider delivers it. China’s internet infrastructure is split across three major carriers, China Telecom, China Unicom, and China Mobile, and each one runs its own separate premium route. Here’s the straightforward version: what those routes actually are, why regular internet transit performs poorly for China-facing traffic in the first place, and what “optimized” actually buys you.</p>
<h2 id="the-baseline-problem">The baseline problem<a href="#the-baseline-problem" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Standard international transit to and from mainland China tends to route through a limited number of congested peering points, and traffic can bounce through several intermediary networks before reaching its destination. Each additional hop adds latency, and congested peering points during high-traffic periods add unpredictable delay on top of that.</p>
<p>For a server hosted outside China serving users inside China, or vice versa, this baseline path is often the single biggest source of poor performance, more significant than server hardware, more significant than distance in raw kilometers.</p>
<h2 id="the-three-carriers-and-their-premium-routes">The three carriers, and their premium routes<a href="#the-three-carriers-and-their-premium-routes" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Since China Telecom, China Unicom, and China Mobile operate largely separate networks with their own international gateways, an optimized route built for one carrier doesn’t automatically carry the same benefit to users on a different carrier’s network. Each has built its own premium product to address the same underlying congestion problem.</p>
<p><strong>China Telecom:</strong> <a href="https://sa.net/blog/china-telecom-premium/" title="CTGNet" target="_blank" rel="noopener noreferrer">CTGNet</a> (formerly marketed as CN2 GIA, AS23764 and AS4809) is China Telecom’s premium tier, built on top of CN2 (China Telecom Next Generation Carrier Network), its broader next-generation backbone. Regular CN2 is an improvement over standard transit but still typically exits through some of the same peering points as ordinary traffic at some point in the route. CTGNet is engineered to stay on China Telecom’s backbone for a longer stretch instead, avoiding the worst of that congestion.</p>
<p><strong>China Unicom:</strong> <a href="https://sa.net/blog/china-unicom-premium/" title="CUP" target="_blank" rel="noopener noreferrer">CUP</a> (China Unicom Premium, AS9929 and AS10099) is China Unicom’s equivalent product, built on the same general principle of minimizing hops and congestion, but specifically for traffic destined to Unicom’s network. If your audience is concentrated on China Unicom connections rather than China Telecom, a route optimized for CUP will generally outperform a CTGNet route for those specific users.</p>
<p><strong>China Mobile:</strong> <a href="https://sa.net/blog/china-mobile-premium/" title="CMIN2" target="_blank" rel="noopener noreferrer">CMIN2</a> (China Mobile International N2, AS58807) is China Mobile’s premium international route, and the newest of the three to become widely available in commercial hosting products. China Mobile has historically been more associated with mobile connectivity than fixed broadband, but its international routing product matters for any service with a meaningful share of China Mobile users, mobile or fixed.</p>
<h2 id="why-this-matters-mechanically-not-just-as-a-marketing-term">Why this matters mechanically, not just as a marketing term<a href="#why-this-matters-mechanically-not-just-as-a-marketing-term" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>The internet doesn’t route traffic based on geographic distance, it routes based on the specific path of networks and peering agreements between them. Two servers that are physically close together can still have a slow, hop-heavy connection if the networks between them aren’t well peered, while two servers physically farther apart can have a fast, direct connection if they sit on well-connected, directly peered networks.</p>
<p>“China-optimized” routing is specifically about improving that network-level path, not the physical distance, which is why a well-routed server thousands of kilometers away can outperform a poorly routed server that’s physically much closer.</p>
<h2 id="what-optimized-concretely-changes">What “optimized” concretely changes<a href="#what-optimized-concretely-changes" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<ul>
<li><strong>Fewer hops.</strong> A well-optimized route stays on fewer networks for a longer portion of the journey, rather than bouncing across several intermediary networks.</li>
<li><strong>Avoiding known congestion points.</strong> Certain peering points between Chinese carriers and international networks are known bottlenecks during peak hours. Optimized routes are specifically engineered to bypass these where possible.</li>
<li><strong>More consistent latency</strong>, not just lower average latency. A congested standard route might have acceptable average latency but wildly inconsistent performance during peak hours, while a genuinely optimized route tends to hold steadier under load.</li>
<li><strong>Carrier-specific benefit.</strong> Since CTGNet, CUP, and CMIN2 each run on a different carrier’s own network, a route optimized for one doesn’t automatically carry the same benefit for traffic destined to a different carrier’s users.</li>
</ul>
<h2 id="what-it-doesnt-fix">What it doesn’t fix<a href="#what-it-doesnt-fix" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Optimized routing improves the network path, but it doesn’t change physical distance entirely, doesn’t guarantee performance to every region within China equally (route quality varies by specific destination, not just by carrier), and doesn’t substitute for adequate server resources on the other end. A well-routed but underpowered server will still perform poorly, the routing and the hardware are separate parts of the overall experience, and it’s worth not conflating “good routing” with “good server.”</p>
<h2 id="how-to-know-if-you-actually-need-it">How to know if you actually need it<a href="#how-to-know-if-you-actually-need-it" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>If your service serves a meaningful volume of users or traffic to or from mainland China, and you’ve noticed latency or reliability issues that a standard international server doesn’t explain, China-optimized routing is worth evaluating specifically. If your China-facing traffic is minimal or occasional, the added cost and provider-selection complexity of finding genuinely optimized routing may not be worth it relative to a standard, well-located server elsewhere in Asia.</p>
<h2 id="how-to-verify-a-specific-providers-claim">How to verify a specific provider’s claim<a href="#how-to-verify-a-specific-providers-claim" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>Ask which specific carrier route is involved (<a href="https://sa.net/blog/cn2-gia-vs-ctgnet-vs-cup-vs-cmin2/" title="CTGNet for China Telecom, CUP for China Unicom, CMIN2 for China Mobile" target="_blank" rel="noopener noreferrer">CTGNet for China Telecom, CUP for China Unicom, CMIN2 for China Mobile</a>) rather than accepting a generic “China-optimized” claim at face value, since the three carriers require genuinely separate optimized paths.</p>
<p>Run a traceroute from or to a Chinese IP address if you can, and look for the route staying on the relevant carrier’s own backbone for a meaningful portion of the path rather than handing off early to a different network. Testing at different times of day, not just once, gives a more complete picture since some providers oversell their optimized capacity and performance can degrade during peak hours.</p>
<h2 id="wrapping-up">Wrapping up<a href="#wrapping-up" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<p>“China-optimized” is a real, meaningful technical claim when it’s backed by an actual premium carrier route like CTGNet, CUP, or CMIN2, and it’s worth understanding the mechanics well enough to tell a genuine claim from a loosely used marketing term. The core idea is simple: keep traffic on a well-connected carrier backbone for as much of the journey as possible, avoiding the congestion points that make standard international transit to and from China unpredictable.</p>
<p>Want to see what an actual optimized route looks like rather than take a marketing claim at face value? Riven Cloud’s <a href="https://sa.net/network/" title="Looking Glass" target="_blank" rel="noopener noreferrer">Looking Glass</a> is public, showing real routing across CTGNet, CUP, and CMIN2 from our Tokyo and Singapore locations. If you’re ready to put it to the test, Riven Cloud’s <a href="https://portal.sa.net/store/cloud-server/premium" title="Premium plan" target="_blank" rel="noopener noreferrer">Premium plan</a> starts at $49/mo and includes that same routing on every tier.</p>
<h2 id="frequently-asked-questions-about-china-optimized-transit">Frequently asked questions about China-optimized transit<a href="#frequently-asked-questions-about-china-optimized-transit" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="#"></a></h2>
<h3 id="do-china-unicom-and-china-mobile-have-their-own-optimized-routes-too">Do China Unicom and China Mobile have their own optimized routes too?<a href="#do-china-unicom-and-china-mobile-have-their-own-optimized-routes-too" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes. CTGNet is specific to China Telecom, but China Unicom and China Mobile have their own equivalent premium routes, CUP and CMIN2 respectively, and a route optimized for one carrier doesn’t automatically benefit users on a different carrier’s network.</p>
<h3 id="does-china-optimized-routing-guarantee-good-performance-everywhere-in-china">Does China-optimized routing guarantee good performance everywhere in China?<a href="#does-china-optimized-routing-guarantee-good-performance-everywhere-in-china" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>No. Route quality can vary by specific region within China, since the physical backbone infrastructure doesn’t reach every area equally well. A route can be genuinely excellent to one city and merely average to another.</p>
<h3 id="how-is-ctgnet-different-from-regular-international-transit">How is CTGNet different from regular international transit?<a href="#how-is-ctgnet-different-from-regular-international-transit" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Regular international transit routes through whatever standard peering arrangements exist between networks, often hitting congested points along the way. CN2 is China Telecom’s improved backbone, and CTGNet specifically is engineered to avoid the worst of that congestion by staying on the backbone longer.</p>
<h3 id="can-i-test-a-providers-china-optimized-routing-before-committing">Can I test a provider’s China-optimized routing before committing?<a href="#can-i-test-a-providers-china-optimized-routing-before-committing" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Yes, and you should. Ask for a trial period or test IP and run a traceroute from or to a Chinese IP address, or check whether the provider offers a public looking glass tool you can test independently.</p>
<h3 id="does-china-optimized-routing-cost-more-than-standard-hosting">Does China-optimized routing cost more than standard hosting?<a href="#does-china-optimized-routing-cost-more-than-standard-hosting" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Generally yes, since it requires specific carrier agreements and infrastructure investment that standard international transit doesn’t. Whether the added cost is worth it depends on how much of your actual traffic is China-facing.</p>
<h3 id="why-do-some-providers-still-call-ctgnet-cn2-gia">Why do some providers still call CTGNet “CN2 GIA”?<a href="#why-do-some-providers-still-call-ctgnet-cn2-gia" class="heading-anchor" aria-hidden="true" tabindex="-1" data-hash="##"></a></h3>
<p>Because CTGNet is the more current name China Telecom uses for a product historically marketed as CN2 GIA, and both terms are still in circulation, sometimes even from the same provider in different places. It’s worth asking directly which tier, and which AS numbers, are actually involved.</p>]]></content>
        <author>
            <name>Riven Cloud OÜ</name>
            <email>sales@riven.cloud</email>
            <uri>https://sa.net/</uri>
        </author>
        <category label="China-Optimized Routing" term="China-Optimized Routing"/>
        <category label="CTGNet" term="CTGNet"/>
        <category label="CUP" term="CUP"/>
        <category label="CMIN2" term="CMIN2"/>
        <category label="China Routing" term="China Routing"/>
        <category label="Carrier Routing" term="Carrier Routing"/>
    </entry>
    <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>
</feed>