<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: overnight.host</title>
    <description>The latest articles on DEV Community by overnight.host (@overnighthost).</description>
    <link>https://dev.to/overnighthost</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3976394%2F8db407ee-a48d-41ca-a769-7d4279957f6d.png</url>
      <title>DEV Community: overnight.host</title>
      <link>https://dev.to/overnighthost</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/overnighthost"/>
    <language>en</language>
    <item>
      <title>Self-hosting WordPress on a VPS: Docker Compose, HTTPS, and the redirect loop nobody explains</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Wed, 16 Sep 2026 15:15:08 +0000</pubDate>
      <link>https://dev.to/overnighthost/self-hosting-wordpress-on-a-vps-docker-compose-https-and-the-redirect-loop-nobody-explains-1pa3</link>
      <guid>https://dev.to/overnighthost/self-hosting-wordpress-on-a-vps-docker-compose-https-and-the-redirect-loop-nobody-explains-1pa3</guid>
      <description>&lt;p&gt;&lt;em&gt;WordPress on a VPS is not hard to start. It is hard to finish: the database credentials, the volume that has to survive an upgrade, and the one setting that sends wp-admin into an endless redirect the moment a reverse proxy sits in front of it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file
&lt;/h2&gt;

&lt;p&gt;Three services: WordPress, MariaDB, and nothing else. Pin both image tags — the comments below say where to check the current release — so an upgrade is a line you change on purpose, not something that lands on a restart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb:11&lt;/span&gt;  &lt;span class="c1"&gt;# check hub.docker.com/_/mariadb/tags for the current point release&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_DATABASE=wordpress&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_USER=wordpress&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db_data:/var/lib/mysql&lt;/span&gt;

  &lt;span class="na"&gt;wordpress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress:6-apache&lt;/span&gt;  &lt;span class="c1"&gt;# check hub.docker.com/_/wordpress/tags for the current point release&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_CONFIG_EXTRA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) &amp;amp;&amp;amp; $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {&lt;/span&gt;
            &lt;span class="s"&gt;$$_SERVER['HTTPS'] = 'on';&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;define('FORCE_SSL_ADMIN', true);&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wp_content:/var/www/html/wp-content&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wp_content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The credentials live in a &lt;code&gt;.env&lt;/code&gt; file next to the compose file, never inline in the YAML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'DB_ROOT_PASSWORD=%s\nDB_PASSWORD=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the volume only covers &lt;code&gt;/var/www/html/wp-content&lt;/code&gt;, not the whole install. That directory is themes, plugins and uploads — the parts that are actually yours. The rest of WordPress core lives inside the image, which is exactly what makes bumping the tag later a clean operation instead of a merge conflict with your own files.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS behind Caddy, and the redirect loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog.example.com {
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole file — Caddy requests and renews the certificate the moment it starts, provided the DNS &lt;code&gt;A&lt;/code&gt; record already points at the machine. Caddy also forwards the original request scheme as the &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; header on every hop, which is where the redirect loop comes from.&lt;/p&gt;

&lt;p&gt;WordPress talks to the &lt;code&gt;wordpress&lt;/code&gt; container over plain HTTP inside the Docker network — TLS ended at Caddy, three hops back. Left alone, WordPress sees &lt;code&gt;HTTP_X_FORWARDED_PROTO=https&lt;/code&gt; from the browser's real request but still thinks the connection to itself is &lt;code&gt;http&lt;/code&gt;, and &lt;code&gt;FORCE_SSL_ADMIN&lt;/code&gt; — which you want, so wp-admin never runs unencrypted — keeps redirecting a page it believes is already loading over HTTP to &lt;code&gt;https&lt;/code&gt;, forever. The three lines in &lt;code&gt;WORDPRESS_CONFIG_EXTRA&lt;/code&gt; above are the fix: they read the header Caddy already sends and set PHP's own &lt;code&gt;$_SERVER['HTTPS']&lt;/code&gt; before WordPress makes that decision, so &lt;code&gt;FORCE_SSL_ADMIN&lt;/code&gt; sees a request that is genuinely HTTPS and stops looping. Skip those lines and the symptom is specific: wp-admin loads fine over plain HTTP direct to the container, then loops the instant Caddy is in front of it.&lt;/p&gt;

&lt;p&gt;Notice every &lt;code&gt;$_SERVER&lt;/code&gt; above is written as &lt;code&gt;$$_SERVER&lt;/code&gt; in the compose file. That's not a typo: Compose does its own &lt;code&gt;$VAR&lt;/code&gt; substitution over every string in &lt;code&gt;docker-compose.yml&lt;/code&gt;, block scalars included, before the file is even parsed as YAML, and &lt;code&gt;$_SERVER&lt;/code&gt; looks exactly like a reference to an environment variable named &lt;code&gt;_SERVER&lt;/code&gt;. Leave the dollar signs single and Compose quietly substitutes an empty string for the (unset) &lt;code&gt;_SERVER&lt;/code&gt; variable, and the broken PHP that results lands straight in &lt;code&gt;wp-config.php&lt;/code&gt; — the site fatals instead of just failing to fix the redirect loop. Doubling the &lt;code&gt;$&lt;/code&gt; is how you tell Compose to leave it alone and pass a literal &lt;code&gt;$&lt;/code&gt; through to PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload limit nobody remembers
&lt;/h2&gt;

&lt;p&gt;WordPress's own media-upload cap is set by PHP, not by anything in &lt;code&gt;wp-admin&lt;/code&gt;. The official image is built on &lt;code&gt;php:apache&lt;/code&gt;, which reads every &lt;code&gt;.ini&lt;/code&gt; file dropped into &lt;code&gt;/usr/local/etc/php/conf.d/&lt;/code&gt;, so a small file mounted read-only does the job without touching the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;file_uploads&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;On&lt;/span&gt;
&lt;span class="py"&gt;memory_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256M&lt;/span&gt;
&lt;span class="py"&gt;upload_max_filesize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;64M&lt;/span&gt;
&lt;span class="py"&gt;post_max_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;64M&lt;/span&gt;
&lt;span class="py"&gt;max_execution_time&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;post_max_size&lt;/code&gt; has to be equal to or larger than &lt;code&gt;upload_max_filesize&lt;/code&gt;, or the smaller one silently wins and a "large" theme zip or plugin archive fails with no useful error. Caddy itself doesn't cap request body size by default the way some reverse proxies do, so with the Caddyfile above there's nothing on Caddy's side to raise — but if you ever add a &lt;code&gt;request_body { max_size ... }&lt;/code&gt; directive to that block, that becomes a second ceiling above &lt;code&gt;post_max_size&lt;/code&gt;, and the smallest number in the chain still wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read this before you buy: the NAT catch for 443
&lt;/h2&gt;

&lt;p&gt;A NAT IPv4 VPS gives you a handful of forwarded ports; whether 443 is among them depends on the plan, so check before you point DNS at the box — and whichever ports you get are forwarded to &lt;em&gt;the machine&lt;/em&gt;, not to any one container. If this box only ever runs one site, that is a non-issue: Caddy binds 443, WordPress sits behind it, done. The catch shows up the moment you want a second HTTPS site or app on the same VPS — you cannot also bind a second process straight to 443, because the port only exists once at the network layer.&lt;/p&gt;

&lt;p&gt;The fix is the one Caddy already gives you for free: run a single Caddy instance as the only thing bound to 443, and route by hostname. Add a second block to the same Caddyfile for a second site, pointed at a different &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;, and Caddy picks the right backend from the &lt;code&gt;Host&lt;/code&gt; header before anything reaches either app. What you cannot do is run WordPress's own container with a direct &lt;code&gt;443:443&lt;/code&gt; port mapping once anything else needs that same port too.&lt;/p&gt;

&lt;p&gt;If you specifically need a standalone IPv4 address with 443 all to itself, that exists but is arranged by e-mail, not something you self-service from the panel. Check what your plan forwards before you commit a domain to it — &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt; and &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; cover the mechanics in more depth than a WordPress guide needs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security basics that matter more than any plugin
&lt;/h2&gt;

&lt;p&gt;None of this is a plugin problem, and installing one rarely fixes it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Few plugins.&lt;/strong&gt; Every plugin is code you didn't audit, running with the same access as WordPress core. A site with five well-maintained plugins is safer than one with thirty, independent of what any of them individually claims to do for security.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic minor updates, on.&lt;/strong&gt; WordPress applies minor and security releases automatically by default; don't turn that off. If you want it explicit in the config anyway, add &lt;code&gt;define('WP_AUTO_UPDATE_CORE', 'minor');&lt;/code&gt; to &lt;code&gt;WORDPRESS_CONFIG_EXTRA&lt;/code&gt; alongside the two lines above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real admin username, not &lt;code&gt;admin&lt;/code&gt;.&lt;/strong&gt; The install wizard already asks you to pick one — use it. &lt;code&gt;admin&lt;/code&gt; is the first guess in every credential-stuffing list on the internet, and a unique username removes half of a brute-force attempt before it starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit repeated login attempts.&lt;/strong&gt; This can be a plugin, but it doesn't have to be: &lt;code&gt;fail2ban&lt;/code&gt; watching your access log for repeated hits on &lt;code&gt;wp-login.php&lt;/code&gt; does the same job at the network layer, without adding another piece of PHP to the site itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Backups: the volume and the database
&lt;/h2&gt;

&lt;p&gt;Two things, and the database changes constantly so it needs its own step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" wordpress'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"wordpress-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sql"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads the root password straight out of the &lt;code&gt;db&lt;/code&gt; container's own environment, so nothing sensitive touches your shell history. For the volume — themes, plugins, uploads, everything that isn't the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; wordpress_wp_content:/data &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/backup alpine:3.20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf &lt;span class="s2"&gt;"/backup/wp-content-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.tar.gz"&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the volume name to whatever &lt;code&gt;docker compose config --volumes&lt;/code&gt; actually prints for your project — Compose prefixes it with the project directory name by default. Copy both files off the VPS entirely; a dump sitting next to the instance it came from is not a backup, it's a file. &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; covers what off-machine actually means in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates: bump the tag, let WordPress do the rest
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the &lt;code&gt;mariadb-dump&lt;/code&gt; above immediately before you do this, not after. WordPress runs its own database upgrade automatically the first time an admin loads a page on the new version — you don't run a separate migration command — but that upgrade is a one-way trip. Read the release notes for the version you're jumping to before you bump the tag, especially across a major version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing
&lt;/h2&gt;

&lt;p&gt;WordPress plus MariaDB plus Caddy is a light stack for a small site with a caching plugin doing its job: &lt;strong&gt;1 GiB of RAM is a workable floor&lt;/strong&gt;, and disk is mostly the media library rather than the application itself.&lt;/p&gt;

&lt;p&gt;The number changes once the site does more than serve pages. WooCommerce adds a real amount of database weight — orders, sessions, product variations — and a page builder like Elementor or Divi runs noticeably heavier PHP per request while you're editing, even if the public-facing page stays fast. &lt;strong&gt;2 GiB is the realistic floor&lt;/strong&gt; once either shows up, and it's worth moving before the site is slow rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. If you want the site without the sysadmin, the managed WordPress container comes with its own hostname and certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click apps&lt;/strong&gt; — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/managed-wordpress" rel="noopener noreferrer"&gt;Order managed-wordpress →&lt;/a&gt; · &lt;a href="https://overnight.host/automation/" rel="noopener noreferrer"&gt;One-click apps overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does wp-admin keep redirecting in a loop behind Caddy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WordPress can see the request was HTTPS from the browser, but doesn't know its own connection to itself counts, so &lt;code&gt;FORCE_SSL_ADMIN&lt;/code&gt; keeps trying to force a scheme it already thinks it's not using. Reading &lt;code&gt;HTTP_X_FORWARDED_PROTO&lt;/code&gt; and setting &lt;code&gt;$_SERVER['HTTPS']&lt;/code&gt; before that check runs, as shown above, is what breaks the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run more than one HTTPS site on the same NAT IPv4 VPS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but only through one process bound to 443. Put Caddy in front of everything and give it one block per hostname; each site's own container stays on a private port that only Caddy talks to. What doesn't work is two containers each trying to bind 443 directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happens if I skip the uploads.ini?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Media uploads and plugin or theme installs fail past PHP's default limit — usually a fairly small number — with an error that looks like the file is corrupt rather than "too big." It's one of the more common "WordPress is broken" reports that's actually a PHP setting no one raised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I already back up the wp-content volume — do I still need a separate database dump?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The volume has your files; the database has your posts, settings, and everything WooCommerce or any plugin stores as rows rather than files. A restore with one and not the other gets you a site with all its files and none of its content, or the reverse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a managed WordPress container different from self-hosting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same application underneath. The difference is who holds the shell: a managed container gives you the site, a hostname and a certificate, and you don't get to edit &lt;code&gt;uploads.ini&lt;/code&gt; or SSH in to run &lt;code&gt;docker compose pull&lt;/code&gt; yourself. If you want that level of control, put it on a VPS instead.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/self-host-wordpress-on-a-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;. We run a small, honest hosting company on dedicated bare metal: Linux &amp;amp; Windows VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>webhosting</category>
    </item>
    <item>
      <title>Docker Compose on a 1 GiB VPS: what actually fits, and how to keep it up</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Wed, 16 Sep 2026 07:15:07 +0000</pubDate>
      <link>https://dev.to/overnighthost/docker-compose-on-a-1-gib-vps-what-actually-fits-and-how-to-keep-it-up-21f7</link>
      <guid>https://dev.to/overnighthost/docker-compose-on-a-1-gib-vps-what-actually-fits-and-how-to-keep-it-up-21f7</guid>
      <description>&lt;p&gt;&lt;em&gt;Docker itself is not the problem on a 1 GiB box — the daemon idles under 50 MB. The problem is that Compose will happily start five containers with no memory limits at all, and the kernel's OOM killer does not ask which one you meant to keep.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Docker Engine, not docker.io
&lt;/h2&gt;

&lt;p&gt;Ubuntu's own repository ships an old, renamed build called &lt;code&gt;docker.io&lt;/code&gt;. Skip it and add Docker's official apt repository instead, so you get current releases and the &lt;code&gt;docker compose&lt;/code&gt; plugin as a first-class package, not the separate Python tool it used to be. The full steps also live in Docker's own &lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;Ubuntu install guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ca-certificates curl
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stable"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your user to the &lt;code&gt;docker&lt;/code&gt; group so you stop typing &lt;code&gt;sudo&lt;/code&gt; in front of every command, then start a new shell for it to take effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here it is &lt;code&gt;docker compose&lt;/code&gt; — no hyphen, a subcommand of the &lt;code&gt;docker&lt;/code&gt; CLI — not the old standalone &lt;code&gt;docker-compose&lt;/code&gt; binary. The compose file syntax is identical either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a swap file before you add containers
&lt;/h2&gt;

&lt;p&gt;A 1 GiB Starter has exactly 1 GiB of RAM and nothing behind it. Without swap, the moment memory pressure crosses that line, the kernel picks a process to kill — sometimes &lt;code&gt;sshd&lt;/code&gt;, not the container you meant to lose. A swap file does not make your workload faster; it turns a hard crash into a slow one you can watch coming with &lt;code&gt;free -h&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gigabytes is a reasonable default on a 25 GB disk. Also lower how eagerly the kernel reaches for it — on a server you want swap as an emergency buffer, not an active memory tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'vm.swappiness=10'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-swappiness.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Read this before you buy: the NAT IPv4 catch
&lt;/h2&gt;

&lt;p&gt;This only matters for containers that need to be reached from outside — a web UI, a webhook, anything with a browser pointed at it. A background worker that only makes outbound calls does not care.&lt;/p&gt;

&lt;p&gt;Cheap VPS plans commonly hand you &lt;strong&gt;NAT IPv4&lt;/strong&gt; — a shared address with a small set of forwarded ports — rather than an address of your own. A reverse proxy that gets a certificate and serves HTTPS needs to own ports 80 and 443 on a public address; a forwarded port like &lt;code&gt;41022&lt;/code&gt; instead means plain &lt;code&gt;https://app.example.com&lt;/code&gt; will not work, because you do not hold 443 on that IP.&lt;/p&gt;

&lt;p&gt;Two honest ways through it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Get a dedicated IPv4.&lt;/strong&gt; Then 80 and 443 are yours and Caddy below works exactly as written. On our plans that is available on request by e-mail, not as a self-service add-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip the reverse proxy entirely for anything that does not need a browser hitting it.&lt;/strong&gt; A sync agent, a backup job, an outbound n8n workflow, a Docker-based CLI tool — none of that needs an inbound port at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; and &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt; before you order if you are not sure which situation you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every service a memory limit
&lt;/h2&gt;

&lt;p&gt;Compose files often show a &lt;code&gt;deploy.resources.limits.memory&lt;/code&gt; block, documented in the &lt;a href="https://docs.docker.com/compose/compose-file/deploy/" rel="noopener noreferrer"&gt;Compose Specification's deploy section&lt;/a&gt;, and leave it at that. On a plain &lt;code&gt;docker compose up&lt;/code&gt;, it is silently ignored — &lt;code&gt;deploy&lt;/code&gt; is written for Swarm, and outside Swarm mode Compose only honors it when you add the &lt;code&gt;--compatibility&lt;/code&gt; flag, which translates it into the same cgroup limit as the older &lt;code&gt;mem_limit&lt;/code&gt; property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--compatibility&lt;/span&gt; up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather not remember a flag on every invocation, use &lt;code&gt;mem_limit&lt;/code&gt; directly. It is a legacy top-level property, still fully supported by the compose plugin, and it applies with a plain &lt;code&gt;docker compose up&lt;/code&gt;. The Uptime Kuma tag below is an old 1.x release kept only to show the syntax, not a recommendation — check the project's GitHub releases page for whatever is actually current before you copy this in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:1.23.16&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200m&lt;/span&gt;
    &lt;span class="na"&gt;memswap_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;400m&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kuma_data:/app/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3001"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kuma_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mem_limit&lt;/code&gt; caps physical memory; &lt;code&gt;memswap_limit&lt;/code&gt; caps memory &lt;em&gt;plus&lt;/em&gt; swap, and doubling it lets the container spill a little into the swap file instead of getting OOM-killed the instant it touches the ceiling. Set both, on every service, before you decide the box "isn't big enough" — an unbounded container competing with three other unbounded containers is a resource-allocation problem, not a RAM problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log rotation, or the disk fills quietly
&lt;/h2&gt;

&lt;p&gt;Docker's default logging driver, &lt;code&gt;json-file&lt;/code&gt;, keeps every line a container has ever printed to stdout, with no size cap out of the box. A chatty service left running for months can grow its log file into gigabytes on a 25 GB disk, and the symptom shows up as "the disk is full" days after the actual cause.&lt;/p&gt;

&lt;p&gt;Set a default at the daemon level so nothing you forget to configure per-service falls through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save that as &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; and restart the daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only applies to containers created after the restart, so recreate anything already running (&lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;) to pick it up. Override it per service in the compose file when a different limit makes sense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three files of ten megabytes each caps that service at 30 MB of logs, rotated automatically, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin every image tag
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;latest&lt;/code&gt; is not a version, it is a promise that the maintainer will not break anything, and that promise gets broken eventually, usually on a night you were not planning to debug anything. Pin an explicit tag, the same way the Uptime Kuma line above does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:1.23.16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump it deliberately with &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt; after reading what changed, not automatically. For the strongest guarantee that a redeploy pulls the exact same bytes, pin the digest instead of the tag (&lt;code&gt;image: name@sha256:...&lt;/code&gt;) — the right trade for anything holding data you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS with Caddy, for the services that need it
&lt;/h2&gt;

&lt;p&gt;For anything with a web UI you intend to open in a browser, Caddy is the shortest path to real HTTPS, because it requests and renews the certificate on its own — the mechanics are covered in &lt;a href="https://caddyserver.com/docs/automatic-https" rel="noopener noreferrer"&gt;Caddy's automatic HTTPS documentation&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.example.com {
    reverse_proxy 127.0.0.1:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole config for one service; add another block for each additional hostname. Bind the container's own port to &lt;code&gt;127.0.0.1&lt;/code&gt;, as in the Uptime Kuma example above, so the only thing reachable from the internet is Caddy itself, not every container's raw port.&lt;/p&gt;

&lt;p&gt;Caddy's own footprint is small — comfortably under 50 MB idle — so it is not what pushes you over 1 GiB; the containers behind it are. Run one Caddy instance and proxy everything through it rather than giving each service its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pruning: the cleanup that isn't automatic
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;docker compose pull&lt;/code&gt; and image rebuild leaves old layers behind. On a 25 GB disk that adds up fast. The safe, routine command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removes only dangling images — layers with no tag pointing at them anymore — and never touches anything a running or stopped container still references. &lt;code&gt;docker system prune -af --volumes&lt;/code&gt; is the aggressive version, and &lt;code&gt;--volumes&lt;/code&gt; is the part to be careful with: it deletes any volume not currently attached to a container, including the data volume of anything you stopped but never removed. Run it by hand, read what it lists before confirming, and never put it in an unattended cron job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fits in 1 GiB, and what does not
&lt;/h2&gt;

&lt;p&gt;With sane limits set, on a 1 GiB Starter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits comfortably.&lt;/strong&gt; Uptime Kuma monitoring a few dozen endpoints, idling under 100 MB. Vaultwarden, a lean Rust rewrite of the Bitwarden server, idling under 50 MB. A small n8n instance running a handful of workflows without heavy concurrent executions — see &lt;a href="https://overnight.host/guides/self-host-n8n-on-a-vps/" rel="noopener noreferrer"&gt;self-hosting n8n on a VPS&lt;/a&gt; for the full setup and its own memory notes. Any one or two of these together, each with a &lt;code&gt;mem_limit&lt;/code&gt; set, leaves real headroom for the OS and Docker itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does not fit, realistically.&lt;/strong&gt; Nextcloud once you turn on preview generation — the PHP-FPM workers that render thumbnails spike hard under real usage and get OOM-killed on a 1 GiB box regardless of how carefully you tune it; &lt;a href="https://overnight.host/guides/self-host-nextcloud-properly/" rel="noopener noreferrer"&gt;self-hosting Nextcloud properly&lt;/a&gt; covers what it actually needs. Gitea plus CI runners — Gitea alone is light, but each CI job the runner picks up spins up its own container with its own memory demand stacked on top of everything already running. Anything built on Elasticsearch — its documented minimum heap alone is larger than this entire machine, before the OS or any other service gets a share.&lt;/p&gt;

&lt;p&gt;The pattern across all three: it is not the base application that breaks 1 GiB, it is a specific feature — previews, CI jobs, a search index — that spikes far above idle. Read the feature list with that in mind, not just a "runs on a Raspberry Pi" marketing line.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. A 1 GiB Starter runs two or three small, memory-limited containers without drama; move up a tier the moment you want Nextcloud with previews, Gitea plus CI runners, or anything built on Elasticsearch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux KVM VPS&lt;/strong&gt; — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/vps-starter" rel="noopener noreferrer"&gt;Order vps-starter →&lt;/a&gt; · &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;Linux KVM VPS overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;deploy.resources.limits.memory&lt;/code&gt; actually do anything with plain &lt;code&gt;docker compose up&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, not by itself. Compose only applies the &lt;code&gt;deploy&lt;/code&gt; section's resource limits when you add the &lt;code&gt;--compatibility&lt;/code&gt; flag; without it, the block is parsed and silently ignored. Either add that flag to every invocation or use the simpler &lt;code&gt;mem_limit&lt;/code&gt; / &lt;code&gt;memswap_limit&lt;/code&gt; properties, which apply on a plain &lt;code&gt;docker compose up&lt;/code&gt; with no flag required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I actually need swap on a VPS, or is that a laptop thing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You need it more on a small VPS than on a laptop with 16 GiB to spare. It is not extra performance — it is the difference between a container getting cleanly OOM-killed and logged versus the kernel picking an arbitrary process, possibly &lt;code&gt;sshd&lt;/code&gt;, when memory runs out with no buffer at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I see what is actually using the memory?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; shows live, per-container cgroup memory and CPU usage, which is what your &lt;code&gt;mem_limit&lt;/code&gt; values are being checked against. Cross-check the host total with &lt;code&gt;free -h&lt;/code&gt; — if &lt;code&gt;docker stats&lt;/code&gt; looks fine but &lt;code&gt;free -h&lt;/code&gt; shows swap in heavy use, something outside your containers, or a container with no limit set, is the culprit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run Nextcloud in 1 GiB if I just don't use previews?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can get it running, but you are fighting the application's own defaults the whole time, and any visit to the Photos or Files app will try to generate a preview unless you disable that server-side too. It is a smaller, more fragile deployment than the guide it deserves; &lt;a href="https://overnight.host/guides/self-host-nextcloud-properly/" rel="noopener noreferrer"&gt;self-hosting Nextcloud properly&lt;/a&gt; is written for the tier that actually suits it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a dedicated IPv4 required just to run Docker Compose?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Docker and Compose do not care about your networking situation at all. A dedicated IPv4 only becomes relevant the moment a service needs inbound HTTPS on a public hostname and your default NAT IPv4 does not forward 80 and 443 to you — and plenty of useful compose stacks never need that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/docker-compose-on-a-1gib-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; — the canonical, kept-current version of this guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>compose</category>
      <category>selfhosted</category>
      <category>linux</category>
    </item>
    <item>
      <title>A self-hosted CI runner on your own VPS: faster builds, and the one rule you must not break</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Tue, 15 Sep 2026 15:15:07 +0000</pubDate>
      <link>https://dev.to/overnighthost/a-self-hosted-ci-runner-on-your-own-vps-faster-builds-and-the-one-rule-you-must-not-break-5a32</link>
      <guid>https://dev.to/overnighthost/a-self-hosted-ci-runner-on-your-own-vps-faster-builds-and-the-one-rule-you-must-not-break-5a32</guid>
      <description>&lt;p&gt;&lt;em&gt;Hosted CI minutes are metered; a small VPS is not. The build cache stays warm, the Docker layers stay pulled, and the machine costs the same whether you push once a week or forty times a day. There is exactly one rule you must not break.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: never on a public repository
&lt;/h2&gt;

&lt;p&gt;A self-hosted runner executes whatever the workflow file says. On a public repository, anyone can open a pull request from a fork — and if your workflow runs on &lt;code&gt;pull_request&lt;/code&gt;, their code runs on your machine. GitHub says this in its own documentation and it is not a theoretical risk: it is how self-hosted runners get turned into crypto miners and credential dumps.&lt;/p&gt;

&lt;p&gt;Private repositories only, unless you are running ephemeral runners inside disposable infrastructure and you know exactly why that changes the answer. If you need CI on a public repo, use the hosted runners. That is what they are good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to size it at
&lt;/h2&gt;

&lt;p&gt;CI is bursty CPU and steady disk. &lt;strong&gt;2 vCPU and 4 GB&lt;/strong&gt; handles typical Node, Python and Go builds without drama; Docker image builds and anything that compiles native code will use everything you give it.&lt;/p&gt;

&lt;p&gt;Disk is the sleeper. Between the workspace, the Docker layer cache, and the package caches, a busy runner will quietly eat tens of gigabytes. Plan to prune (below), and know how you would grow the disk before you need to — &lt;a href="https://overnight.host/help/add-extra-storage-to-your-vps/" rel="noopener noreferrer"&gt;add extra storage to your VPS&lt;/a&gt; covers that on our machines.&lt;/p&gt;

&lt;p&gt;One pleasant surprise: a runner only makes &lt;strong&gt;outbound&lt;/strong&gt; connections. It polls the CI service for work; nothing connects in. That means &lt;strong&gt;NAT IPv4 is completely fine&lt;/strong&gt; here — no dedicated address, no port forwarding, nothing to configure. It is one of the few server workloads with no networking caveat at all (&lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; if you want the background).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the machine and a non-root user
&lt;/h2&gt;

&lt;p&gt;Deploy an &lt;a href="https://overnight.host/ubuntu-vps/" rel="noopener noreferrer"&gt;Ubuntu LTS image&lt;/a&gt; and do the basics first: &lt;a href="https://overnight.host/help/connect-to-your-vps-over-ssh/" rel="noopener noreferrer"&gt;connect to your VPS over SSH&lt;/a&gt; and &lt;a href="https://overnight.host/help/secure-your-vps/" rel="noopener noreferrer"&gt;secure your VPS&lt;/a&gt;. Then make a user for the runner that is &lt;em&gt;not&lt;/em&gt; root and &lt;em&gt;not&lt;/em&gt; you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser &lt;span class="nt"&gt;--disabled-password&lt;/span&gt; &lt;span class="nt"&gt;--gecos&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your jobs need Docker, adding &lt;code&gt;runner&lt;/code&gt; to the &lt;code&gt;docker&lt;/code&gt; group grants effectively root on the host, because it can mount the host filesystem into a container. That is an acceptable trade on a single-purpose machine that only builds your own private code. It is not acceptable on a machine that does anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: GitHub Actions runner
&lt;/h2&gt;

&lt;p&gt;Get the download command and registration token from &lt;strong&gt;Settings → Actions → Runners → New self-hosted runner&lt;/strong&gt; on the repository or organisation — the token is short-lived, so generate it when you are ready to use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; runner &lt;span class="nt"&gt;-i&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;actions-runner &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;actions-runner
curl &lt;span class="nt"&gt;-o&lt;/span&gt; actions-runner-linux-x64.tar.gz &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf actions-runner-linux-x64.tar.gz
./config.sh &lt;span class="nt"&gt;--url&lt;/span&gt; https://github.com/YOURORG/YOURREPO &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--token&lt;/span&gt; YOUR_REGISTRATION_TOKEN &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--labels&lt;/span&gt; self-hosted,linux,x64,vps &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--unattended&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install it as a service so it survives a reboot. This part runs as root because it writes a systemd unit, but the service itself runs as &lt;code&gt;runner&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exit
cd&lt;/span&gt; /home/runner/actions-runner
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./svc.sh &lt;span class="nb"&gt;install &lt;/span&gt;runner
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./svc.sh start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Target it from a workflow with the labels you set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;self-hosted&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;linux&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;vps&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: make it ephemeral if you can
&lt;/h2&gt;

&lt;p&gt;A long-lived runner accumulates state: leftover files, mutated global config, a &lt;code&gt;node_modules&lt;/code&gt; from three branches ago. Worse, one job can leave something behind that the next job picks up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;./config.sh --ephemeral&lt;/code&gt; makes the runner take exactly one job and then deregister. Pair it with a supervisor that re-registers a fresh one and you get the isolation of hosted CI with the warmth of your own cache. It is more moving parts; it is also the difference between "the build only fails on the runner" and a machine you trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: GitLab, if that is where you live
&lt;/h2&gt;

&lt;p&gt;Same shape, different words. Install &lt;code&gt;gitlab-runner&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;gitlab-runner register &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--non-interactive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://gitlab.com/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; YOUR_RUNNER_TOKEN &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--executor&lt;/span&gt; docker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-image&lt;/span&gt; alpine:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;docker executor&lt;/strong&gt; is the one you want: each job gets a fresh container, so the isolation problem from step 3 is solved by default. Its cost is that Docker layer caching between jobs needs deliberate configuration rather than happening for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: stop it filling the disk
&lt;/h2&gt;

&lt;p&gt;This is the maintenance that actually matters. A weekly timer is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker system prune &lt;span class="nt"&gt;-af&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="s2"&gt;"until=168h"&lt;/span&gt;
docker volume prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a cleanup of the runner's &lt;code&gt;_work&lt;/code&gt; directory if your jobs leave large artefacts behind, and put a disk-usage alert somewhere you will see it. A CI runner that is 100% full does not fail loudly — it fails weirdly, halfway through a step, with an error about something unrelated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: secrets
&lt;/h2&gt;

&lt;p&gt;The runner holds real credentials in memory while a job runs. Two habits worth having from day one: keep the machine single-purpose, so nothing else on it can read process memory or the workspace, and prefer short-lived tokens (OIDC to your cloud provider) over long-lived keys stored in repository secrets. If a token can only be exchanged for fifteen minutes of access, a compromised job is an incident rather than a catastrophe.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. A runner is a steady, boring workload — two cores and a few gigabytes, up all month, which is exactly what a monthly VPS is priced for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux KVM VPS&lt;/strong&gt; — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/vps-starter" rel="noopener noreferrer"&gt;Order vps-starter →&lt;/a&gt; · &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;Linux KVM VPS overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a self-hosted runner cheaper than hosted minutes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It stops being metered, which is the point. A machine that costs the same every month is easier to reason about than a minute counter, and the warm cache often makes builds faster on modest hardware than a cold hosted runner on fast hardware. Whether it is &lt;em&gt;cheaper&lt;/em&gt; depends entirely on how much you build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use a self-hosted runner on a public repository?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You should not. A pull request from a fork can run arbitrary code on your machine. Keep self-hosted runners on private repositories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much disk does a runner need?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More than you think — the workspace plus the Docker layer cache plus package caches. Start at 40 GB or more for anything that builds containers, and prune on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a runner need a dedicated IP address?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. It only makes outbound connections, so NAT IPv4 with no forwarded ports works perfectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I run one runner or several?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One per concurrent job you want. Two small runners on separate machines beat one large runner for throughput, because CI parallelism is mostly about not queueing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/self-hosted-ci-runner-on-a-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; — the canonical, kept-current version of this guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>github</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Self-hosting code-server on a VPS: the honest RAM math, Caddy HTTPS, and the Open VSX catch</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:15:07 +0000</pubDate>
      <link>https://dev.to/overnighthost/self-hosting-code-server-on-a-vps-the-honest-ram-math-caddy-https-and-the-open-vsx-catch-2ll6</link>
      <guid>https://dev.to/overnighthost/self-hosting-code-server-on-a-vps-the-honest-ram-math-caddy-https-and-the-open-vsx-catch-2ll6</guid>
      <description>&lt;p&gt;&lt;em&gt;code-server is Coder's build of VS Code that runs on a server and serves the whole editor to any browser. On a VPS it turns into a personal cloud IDE you can open from a tablet, a work laptop with no admin rights, or a machine you don't trust with your SSH keys — but it is still a process on a small box, and the honest framing matters more than the install command.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is actually for, and what it isn't
&lt;/h2&gt;

&lt;p&gt;A browser IDE on a 2 GiB VPS is for editing, running &lt;code&gt;git&lt;/code&gt;, and light builds: a script, a static site, a small service you &lt;code&gt;go build&lt;/code&gt; or &lt;code&gt;npm run build&lt;/code&gt; locally on the box. It is not a replacement for a CI runner or a beefy workstation. The editor itself is lightweight, but the moment you open a real project, &lt;strong&gt;language servers and extensions are what eat the RAM&lt;/strong&gt;, not the VS Code shell around them — a TypeScript server indexing a medium repo, &lt;code&gt;rust-analyzer&lt;/code&gt;, or Pylance-equivalents each want their own few hundred megabytes, and they stay resident for as long as the folder is open.&lt;/p&gt;

&lt;p&gt;That's the number to plan around, not the idle footprint. code-server itself idles well under 200 MB. Add one active language server and a handful of extensions and a real session settles closer to 700 MB–1 GiB, before the OS and Caddy. &lt;strong&gt;1 GiB works only for light editing&lt;/strong&gt; — config files, short scripts, no language server doing real indexing. &lt;strong&gt;2 GiB is the realistic floor&lt;/strong&gt; for a genuine coding session with one language server running. Compile anything non-trivial alongside it, or run a second always-on service on the same box, and that's a reason to size up rather than watch the OOM killer pick a fight with &lt;code&gt;sshd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing code-server
&lt;/h2&gt;

&lt;p&gt;Coder publishes an install script, and the right way to run it is to read it first, not pipe it straight into a shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://code-server.dev/install.sh &lt;span class="nt"&gt;-o&lt;/span&gt; install-code-server.sh
less install-code-server.sh
sh install-code-server.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Ubuntu the script detects the distribution and installs the &lt;code&gt;.deb&lt;/code&gt; package it downloads, the same artifact published on the project's GitHub releases page. If you'd rather skip the script entirely, grab that &lt;code&gt;.deb&lt;/code&gt; yourself and install it with &lt;code&gt;apt&lt;/code&gt;, which resolves dependencies for you where a bare &lt;code&gt;dpkg -i&lt;/code&gt; would not. code-server's release assets carry the version in the filename (&lt;code&gt;code-server_4.92.2_amd64.deb&lt;/code&gt;, not a bare &lt;code&gt;code-server_amd64.deb&lt;/code&gt;), so there's no unversioned shortcut URL — resolve the current tag first, then build the filename from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://api.github.com/repos/coder/code-server/releases/latest | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Po&lt;/span&gt; &lt;span class="s1"&gt;'"tag_name": *"v\K[^"]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; code-server.deb &lt;span class="s2"&gt;"https://github.com/coder/code-server/releases/download/v&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/code-server_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_amd64.deb"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; ./code-server.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either path installs the same package. Check the &lt;a href="https://github.com/coder/code-server/releases" rel="noopener noreferrer"&gt;code-server releases page&lt;/a&gt; if you'd rather copy the current filename by hand instead of scripting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it as yourself, not as root
&lt;/h2&gt;

&lt;p&gt;The package ships a systemd &lt;strong&gt;template&lt;/strong&gt; unit, &lt;code&gt;code-server@.service&lt;/code&gt;, meant to run as your normal non-root user — the &lt;code&gt;@&lt;/code&gt; takes the username. Never run an internet-facing editor as root; it has a terminal, and a terminal as root is the whole machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; code-server@&lt;span class="nv"&gt;$USER&lt;/span&gt;
systemctl status code-server@&lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That starts code-server under your own account, reading its config from your own home directory, and restarts it automatically after a reboot or a crash. &lt;code&gt;sudo journalctl -u code-server@$USER -f&lt;/code&gt; shows the live log if something isn't coming up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config file: bind-addr, auth, and what &lt;code&gt;auth: none&lt;/code&gt; really means
&lt;/h2&gt;

&lt;p&gt;First launch writes &lt;code&gt;~/.config/code-server/config.yaml&lt;/code&gt; with a random password already in it. Open it and set it deliberately instead of trusting the generated one blindly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;bind-addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:8080&lt;/span&gt;
&lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
&lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;a long, random password, not the generated one if you didn't check it&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;cert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bind-addr&lt;/code&gt; on &lt;code&gt;127.0.0.1:8080&lt;/code&gt; means code-server only answers on the loopback interface — nothing on the open internet can reach port 8080 directly, only whatever reverse proxy runs on the same machine. Leave &lt;code&gt;cert: false&lt;/code&gt; here; Caddy is doing TLS, not code-server itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;auth: none&lt;/code&gt; exists, and it is not automatically wrong — but it is only sane when something else in front of code-server is doing the authenticating, a VPN you connect over first, or basic auth on the proxy in front of it (below). Turn off code-server's own password and put nothing else in its place, and you've published a shell to anyone who finds the hostname.&lt;/p&gt;

&lt;p&gt;Restart after any change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart code-server@&lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  HTTPS with Caddy — a browser IDE has no business on plain HTTP
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code.example.com {
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole file, and it's enough because Caddy detects the WebSocket upgrade code-server's UI depends on and proxies it without extra configuration. Point the domain's &lt;code&gt;A&lt;/code&gt; record at the VPS, let it propagate, and only then start Caddy — automatic HTTPS needs that record correct before it can request a certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read this before you buy: the NAT IPv4 catch for 443.&lt;/strong&gt; Some overnight.host plans hand you 80 and 443 forwarded by default, some don't — check what your plan actually forwards before you point DNS anywhere, at &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt;. If 443 isn't reachable, Caddy's automatic certificate issuance has nothing to answer the ACME challenge on, and it will sit there retrying instead of serving anything. A dedicated IPv4, where 443 is genuinely yours — on our plans that's arranged by e-mail — is the alternative; &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; covers the trade-off in more detail. Either way, a browser IDE must never end up reachable over plain HTTP on a public port — that password is the only thing standing between the internet and a terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  An IDE on the internet is a shell on your VPS
&lt;/h2&gt;

&lt;p&gt;Say it plainly: code-server's whole value is a full terminal inside the browser tab, which means anyone who gets past &lt;code&gt;auth: password&lt;/code&gt; has a shell on your machine, not just an editor. There's no built-in two-factor here — the password is doing all the work. A long, random, unique password is the floor, generated with something like &lt;code&gt;openssl rand -base64 24&lt;/code&gt; and stored in a password manager, not typed from memory.&lt;/p&gt;

&lt;p&gt;Two ways to add a real second layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Caddy basic auth in front of it&lt;/strong&gt;, which stacks a second credential Caddy checks before it ever proxies to code-server:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code.example.com {
    basic_auth {
        &amp;lt;username&amp;gt; &amp;lt;bcrypt-hash-from-caddy-hash-password&amp;gt;
    }
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A VPN in front of the whole thing&lt;/strong&gt;, so &lt;code&gt;bind-addr&lt;/code&gt; and the Caddy site block only ever answer on an interface reachable through the tunnel, and the public internet never sees the hostname resolve to anything useful at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick at least one of these if the machine holds anything you'd mind losing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extensions come from Open VSX, not the Microsoft marketplace
&lt;/h2&gt;

&lt;p&gt;code-server is configured out of the box to pull extensions from &lt;strong&gt;Open VSX&lt;/strong&gt; (open-vsx.org), the open registry, rather than Microsoft's own marketplace — Microsoft's marketplace terms restrict its use to Microsoft's own products, and code-server isn't one. Install extensions the normal way, from the Extensions view in the UI or with &lt;code&gt;code-server --install-extension &amp;lt;publisher.extension&amp;gt;&lt;/code&gt;, and it resolves against Open VSX automatically.&lt;/p&gt;

&lt;p&gt;The catch worth knowing before you go looking for something: not every extension on the Microsoft marketplace has a matching listing on Open VSX. A handful of Microsoft-published ones simply aren't mirrored there, so if you're coming from desktop VS Code, budget a few minutes to confirm your must-haves exist before you build a whole workflow around them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings Sync&lt;/strong&gt; exists in code-server too, tied to a Microsoft or GitHub account, but it's worth treating as a convenience rather than a guarantee: because extensions still come from Open VSX on this end, syncing settings pulled from a desktop VS Code profile brings back the extensions that also exist on Open VSX and quietly skips the ones that don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workspaces, and git over your dedicated SSH port
&lt;/h2&gt;

&lt;p&gt;Your projects live as ordinary directories under your home directory on the VPS's own disk — no separate storage layer to configure, just &lt;code&gt;~/projects&lt;/code&gt; or wherever you'd naturally put them, opened from code-server's &lt;strong&gt;File → Open Folder&lt;/strong&gt;. A handful of repos and their &lt;code&gt;node_modules&lt;/code&gt; or build artifacts add up faster than the code itself, so keep an eye on &lt;code&gt;df -h&lt;/code&gt; the same way you would on any other box.&lt;/p&gt;

&lt;p&gt;Git works from the built-in terminal exactly as it would over SSH anywhere else — generate or copy an SSH keypair for this user, add the public half to GitHub, GitLab, or wherever your remotes live. The one thing that catches people out: if one of those remotes is a git server you also self-host behind NAT IPv4 — our own &lt;a href="https://overnight.host/guides/self-host-gitea-on-a-vps/" rel="noopener noreferrer"&gt;self-hosting Gitea on a VPS&lt;/a&gt; guide walks through exactly this — its SSH clone URL needs that server's &lt;em&gt;forwarded&lt;/em&gt; port, not 22, so a plain &lt;code&gt;git clone git@host:repo.git&lt;/code&gt; fails until you either add a &lt;code&gt;Port&lt;/code&gt; line to &lt;code&gt;~/.ssh/config&lt;/code&gt; for that host or spell the port out in the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups and updates
&lt;/h2&gt;

&lt;p&gt;Two directories matter, and they're small: &lt;code&gt;~/.config/code-server&lt;/code&gt; (your &lt;code&gt;config.yaml&lt;/code&gt;, including the password) and &lt;code&gt;~/.local/share/code-server&lt;/code&gt; (installed extensions and editor state), alongside whatever workspace directories you actually work in. None of that is backed up by anything running on the box on its own — copy it off the VPS on a schedule, the same as anything else you'd mind losing; &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; covers what that looks like in practice.&lt;/p&gt;

&lt;p&gt;Updating means installing a newer package: re-run the install script, which fetches the current release, or download the latest &lt;code&gt;.deb&lt;/code&gt; from the releases page and &lt;code&gt;sudo apt install&lt;/code&gt; it again the same way you did the first time, then &lt;code&gt;sudo systemctl restart code-server@$USER&lt;/code&gt; to pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. The 2 GiB Basic is the realistic floor once a language server is actually running; the 1 GiB Starter only holds up for quick edits, and a build that leans on the CPU or a second service on the same box is a reason to move up to the 4 GiB Standard, not to fight the swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux KVM VPS&lt;/strong&gt; — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/vps-basic" rel="noopener noreferrer"&gt;Order vps-basic →&lt;/a&gt; · &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;Linux KVM VPS overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can code-server compile a real project, not just edit files?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For light builds, yes — a script, a small service, a static site generator. For anything CPU- or memory-heavy, treat the VPS as an editor with a terminal attached, not a build farm, and run the actual compile step somewhere sized for it, or budget real RAM and CPU if you insist on doing it here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a strong password enough, or do I need HTTPS too?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both, always. HTTPS via Caddy stops the password itself from crossing the network in plain text; without it, anyone between you and the VPS can read the password off the wire the first time you type it in. A browser IDE reachable over plain HTTP is not a smaller risk than one with a weak password — it's the same risk from a different angle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is an extension I use every day missing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;code-server pulls from Open VSX, not the Microsoft marketplace, and a small number of extensions — mostly Microsoft's own — are only published to the latter. Search Open VSX directly before assuming your install is broken; if it isn't listed there, it isn't coming through the Extensions view no matter what you try.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I browse or edit files outside my home directory?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only as whatever your own Linux user can read or write, same as any shell session — code-server runs as you, under the systemd unit's &lt;code&gt;%i&lt;/code&gt; substitution, with no extra privilege of its own. If a file needs &lt;code&gt;sudo&lt;/code&gt; to touch from a terminal, it needs &lt;code&gt;sudo&lt;/code&gt; here too, and code-server doesn't hand that out automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my work if I just close the browser tab?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing is lost. code-server runs as a systemd service on the VPS independent of any browser tab; closing the tab just disconnects the view. Unsaved editor state and any process you left running in an integrated terminal survive until you reconnect, restart the service yourself, or reboot the machine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/self-host-code-server-on-a-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;. We run a small, honest hosting company on dedicated bare metal: Linux &amp;amp; Windows VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codeserver</category>
      <category>vscode</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Self-hosting Gitea on a VPS: SQLite vs Postgres, HTTPS, and the SSH port nobody warns you about</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Mon, 14 Sep 2026 07:15:06 +0000</pubDate>
      <link>https://dev.to/overnighthost/self-hosting-gitea-on-a-vps-sqlite-vs-postgres-https-and-the-ssh-port-nobody-warns-you-about-8h8</link>
      <guid>https://dev.to/overnighthost/self-hosting-gitea-on-a-vps-sqlite-vs-postgres-https-and-the-ssh-port-nobody-warns-you-about-8h8</guid>
      <description>&lt;p&gt;&lt;em&gt;Gitea is a full GitHub-shaped forge — repos, issues, pull requests, a package registry, and now its own CI — in one small Go binary. The web half installs in five minutes. The part that actually costs an evening is git-over-SSH, because your VPS's SSH port is already spoken for.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite or Postgres, and when it actually matters
&lt;/h2&gt;

&lt;p&gt;Gitea, unlike some self-hosted apps that only pretend SQLite is an option, genuinely supports it in production for small teams. A single maintainer or a handful of collaborators pushing to a dozen private repos will not notice the database at all — it is one file, backed up by copying it, and there is nothing else to run.&lt;/p&gt;

&lt;p&gt;Move to Postgres once either becomes true: several people are pushing and opening pull requests at the same time and you want real concurrent writes, or you are running Gitea Actions on the same box, where the runner and the web UI can both hit the database while a job is in flight. Postgres also makes backups cleaner later — a &lt;code&gt;pg_dump&lt;/code&gt; is a text stream you can diff and restore selectively, where a SQLite file backup is all-or-nothing.&lt;/p&gt;

&lt;p&gt;There is no in-place conversion between the two: outgrowing SQLite means exporting what you can, or scripting a migration, rather than flipping a config flag — decide early if you already know you'll have more than one or two regular committers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing: 1 GiB alone, 2 GiB once Actions run
&lt;/h2&gt;

&lt;p&gt;Gitea itself is light. The Go binary, a handful of goroutines, and either SQLite or a thin Postgres connection: &lt;strong&gt;1 GiB of RAM is genuinely comfortable&lt;/strong&gt; for a personal instance, Caddy included.&lt;/p&gt;

&lt;p&gt;The number that changes this is Actions. A CI job is a short-lived container doing real work — compiling, running a test suite, building an image — and while it runs it competes for the same RAM as Gitea and Postgres. Turn Actions on and point a runner at the same VPS, and plan for &lt;strong&gt;2 GiB&lt;/strong&gt; as the realistic floor, more if your builds pull large base images or compile anything non-trivial. Disk follows the same pattern: repos are usually small, but the Docker layer cache a runner accumulates is not, so keep an eye on it once jobs run regularly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file
&lt;/h2&gt;

&lt;p&gt;Pin the image tag rather than tracking &lt;code&gt;latest&lt;/code&gt;, so an upgrade is something you choose, not something that happens on a restart — the comment on the image line below is where to check for the current minor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:1.22&lt;/span&gt;  &lt;span class="c1"&gt;# check hub.docker.com/r/gitea/gitea/tags for the current minor&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__server__DOMAIN=git.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__server__ROOT_URL=https://git.example.com/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__server__SSH_DOMAIN=git.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__server__SSH_PORT=2222&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__server__SSH_LISTEN_PORT=22&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__security__INSTALL_LOCK=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__service__DISABLE_REGISTRATION=true&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gitea_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2222:22"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web port is bound to &lt;code&gt;127.0.0.1&lt;/code&gt; on purpose, so only the reverse proxy on the same host can reach it. The SSH port is deliberately not loopback-bound, because it has to be reachable from wherever you push from; more on the number &lt;code&gt;2222&lt;/code&gt; below.&lt;/p&gt;

&lt;p&gt;To move the database to Postgres, add a second service and point Gitea at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db_data:/var/lib/postgresql/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;db_data:&lt;/code&gt; next to &lt;code&gt;gitea_data:&lt;/code&gt; under the top-level &lt;code&gt;volumes:&lt;/code&gt; block — Compose won't start a service that references an undeclared volume.&lt;/p&gt;

&lt;p&gt;and add to the &lt;code&gt;gitea&lt;/code&gt; service's &lt;code&gt;environment&lt;/code&gt;, plus &lt;code&gt;depends_on: [db]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__DB_TYPE=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__HOST=db:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__NAME=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__PASSWD=${DB_PASSWORD}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the password in a &lt;code&gt;.env&lt;/code&gt; file next to the compose file, not inline in the YAML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'DB_PASSWORD=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring the stack up, then create the admin account from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; git gitea gitea admin user create &lt;span class="nt"&gt;--username&lt;/span&gt; &amp;lt;you&amp;gt; &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;strong-password&amp;gt;'&lt;/span&gt; &lt;span class="nt"&gt;--email&lt;/span&gt; you@example.com &lt;span class="nt"&gt;--admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GITEA__security__INSTALL_LOCK=true&lt;/code&gt; above skips Gitea's &lt;code&gt;/install&lt;/code&gt; wizard — without it, that page sits open and unauthenticated until you finish it by hand. &lt;code&gt;GITEA__service__DISABLE_REGISTRATION=true&lt;/code&gt; matters too: Gitea's default is open self-registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS with Caddy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git.example.com {
    reverse_proxy 127.0.0.1:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole file. Caddy requests and renews the certificate for this hostname automatically as soon as it starts or reloads with this config, provided the &lt;code&gt;A&lt;/code&gt; record already resolves to the machine — point DNS first, let it settle, and only then start Caddy.&lt;/p&gt;

&lt;p&gt;Ports 80 and 443 aren't guaranteed forwarded either — some plans hand them to you by default, some don't, and Caddy needs at least one reachable to get a certificate. Check what your plan forwards before pointing DNS at the box; &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt; is the same page the SSH story below sends you to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read this before you buy: the SSH port story
&lt;/h2&gt;

&lt;p&gt;This is the part every other Gitea write-up glosses over. Your VPS almost certainly reaches SSH on a &lt;strong&gt;dedicated, non-standard port&lt;/strong&gt; already — that is how &lt;code&gt;sshd&lt;/code&gt; is exposed under NAT IPv4, and it is not something you can also hand to a container. Gitea's own SSH server needs a &lt;em&gt;second&lt;/em&gt; forwarded port pointed at it, mapped to the container's internal port 22.&lt;/p&gt;

&lt;p&gt;Concretely: your provider gives you a small number of forwarded ports besides the one used for host SSH. Say the next is &lt;code&gt;41023&lt;/code&gt;. You map it in compose as &lt;code&gt;"41023:22"&lt;/code&gt; and set &lt;code&gt;GITEA__server__SSH_PORT=41023&lt;/code&gt; to match — but keep &lt;code&gt;GITEA__server__SSH_LISTEN_PORT=22&lt;/code&gt; right next to it, the way the compose file above already does. &lt;code&gt;SSH_PORT&lt;/code&gt; only controls what Gitea &lt;em&gt;advertises&lt;/em&gt; in clone URLs; &lt;code&gt;SSH_LISTEN_PORT&lt;/code&gt; controls what it actually listens on inside the container, and it quietly defaults to whatever &lt;code&gt;SSH_PORT&lt;/code&gt; is set to. Change one without pinning the other to &lt;code&gt;22&lt;/code&gt;, and Gitea's internal listener moves off port 22 too — nothing answers your &lt;code&gt;"41023:22"&lt;/code&gt; mapping anymore, so git-over-SSH fails outright, connection refused, not just a wrong URL in the UI.&lt;/p&gt;

&lt;p&gt;If you would rather not deal with a second forwarded port at all, &lt;code&gt;git push&lt;/code&gt;/&lt;code&gt;git clone&lt;/code&gt; over HTTPS with a personal access token works identically and needs nothing beyond the port Caddy already uses. Generate the token under &lt;strong&gt;Settings → Applications&lt;/strong&gt; in Gitea's UI, and use it as the password when the CLI or credential helper asks. This is the honest fallback, not a downgrade — plenty of people run Gitea for years on HTTPS-only clones and never touch the SSH port.&lt;/p&gt;

&lt;p&gt;Worth reading first: &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; and &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gitea Actions, and where the runner should live
&lt;/h2&gt;

&lt;p&gt;Gitea Actions speaks most of the GitHub Actions workflow syntax, so &lt;code&gt;.gitea/workflows/*.yml&lt;/code&gt; files using common actions mostly just work. It ships disabled; turn it on with &lt;code&gt;GITEA__actions__ENABLED=true&lt;/code&gt; and restart.&lt;/p&gt;

&lt;p&gt;Actions itself does no building — it dispatches jobs to &lt;code&gt;act_runner&lt;/code&gt;, a separate process that registers against your instance and polls for work. Generate a registration token from inside the running container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; git gitea gitea actions generate-runner-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the printed token into &lt;code&gt;.env&lt;/code&gt; as &lt;code&gt;RUNNER_TOKEN=&amp;lt;token&amp;gt;&lt;/code&gt; — the runner block below reads it from there, and an empty value means a runner that never registers.&lt;/p&gt;

&lt;p&gt;then point a runner at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/act_runner:0.2.11&lt;/span&gt;  &lt;span class="c1"&gt;# check gitea.com/gitea/act_runner's tags for the current release&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_INSTANCE_URL=https://git.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_NAME=vps-runner&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;runner_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;runner_data:&lt;/code&gt; to that same &lt;code&gt;volumes:&lt;/code&gt; block too (and &lt;code&gt;db_data:&lt;/code&gt;, if you added Postgres).&lt;/p&gt;

&lt;p&gt;That last mount is the thing to be honest about: giving the runner the Docker socket gives it effective root on the host, since it can start a container with the host filesystem mounted in. On a box that only runs Gitea and its own private repos, that's a reasonable trade. &lt;a href="https://overnight.host/guides/self-hosted-ci-runner-on-a-vps/" rel="noopener noreferrer"&gt;a self-hosted CI runner on a VPS&lt;/a&gt; covers sizing and isolation if your builds get heavier, or you want the runner on its own machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups with gitea dump
&lt;/h2&gt;

&lt;p&gt;Gitea ships its own backup command, and it's the one to use — it snapshots the database, repos, hooks, and configuration together, instead of leaving you to reconstruct which copies were consistent with each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; git gitea gitea dump &lt;span class="nt"&gt;-c&lt;/span&gt; /data/gitea/conf/app.ini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run without a Postgres service attached, this also captures the SQLite file. The resulting zip lands inside the container's data directory; copy it off before it does anyone any good:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; gitea sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ls -t /data/gitea/gitea-dump-*.zip | head -n1'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-q&lt;/span&gt; gitea&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"./gitea-dump-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.zip"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy that file off the VPS entirely — a dump on the same disk as the instance it came from is a file, not a backup. &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; covers what off-machine backup means in practice. If you moved to Postgres, the dump still includes a database export, so a separate &lt;code&gt;pg_dump&lt;/code&gt; is only needed for a faster point-in-time restore of a large database on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump the pinned tag deliberately, read the release notes for the version you're jumping to, and take a &lt;code&gt;gitea dump&lt;/code&gt; immediately beforehand — Gitea runs migrations automatically on first start after an upgrade, and a migration you can roll back from is one where the backup came ten minutes earlier, not one you're improvising after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. If you want the repos without the sysadmin, the managed Gitea container comes with its own hostname and certificate — you get the app and a URL, not a root shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click apps&lt;/strong&gt; — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/one-click-gitea" rel="noopener noreferrer"&gt;Order one-click-gitea →&lt;/a&gt; · &lt;a href="https://overnight.host/automation/" rel="noopener noreferrer"&gt;One-click apps overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use SQLite for a real team, or is it just for testing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a small team it is a real, supported option, not a toy — Gitea's SQLite backend handles a handful of concurrent users comfortably. Move to Postgres once several people are pushing at once, or you're running Actions on the same box — both add write concurrency SQLite wasn't designed for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why can't I just use my VPS's normal SSH port for git?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it is already in use by &lt;code&gt;sshd&lt;/code&gt; for logging into the machine itself, and one port cannot be forwarded to two different listeners. Gitea's own SSH server needs its own forwarded port, mapped to the container, with &lt;code&gt;SSH_PORT&lt;/code&gt; set to match for correct clone URLs and &lt;code&gt;SSH_LISTEN_PORT&lt;/code&gt; pinned to &lt;code&gt;22&lt;/code&gt; so the container's own listener still matches that mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a dedicated IPv4 for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. A second forwarded port for Gitea's SSH server is enough, and if your provider doesn't hand you a spare one, HTTPS with a personal access token works identically with no extra port at all. A dedicated IPv4 only matters if you want a standard port number instead; on our plans that's arranged by e-mail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should the Actions runner live?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the same VPS is fine at small scale, as long as you budget the RAM for it and accept the runner's Docker socket access as host-level power. Once builds get heavy, or you want a boundary between the git server and whatever your CI jobs execute, move the runner to its own box — &lt;a href="https://overnight.host/guides/self-hosted-ci-runner-on-a-vps/" rel="noopener noreferrer"&gt;a self-hosted CI runner on a VPS&lt;/a&gt; walks through sizing and isolation for that case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;gitea dump&lt;/code&gt; actually back up?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database (including a SQLite file, if that's what you're running), the repository data, custom configuration, hooks, and logs, bundled into one zip with &lt;code&gt;-c&lt;/code&gt; pointing at your &lt;code&gt;app.ini&lt;/code&gt; — the one command that guarantees the pieces are consistent with each other, which copying files by hand doesn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/self-host-gitea-on-a-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; — the canonical, kept-current version of this guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gitea</category>
      <category>git</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>Your own WireGuard VPN on a VPS: the NAT port has to match</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Sun, 13 Sep 2026 19:15:16 +0000</pubDate>
      <link>https://dev.to/overnighthost/your-own-wireguard-vpn-on-a-vps-the-nat-port-has-to-match-k6n</link>
      <guid>https://dev.to/overnighthost/your-own-wireguard-vpn-on-a-vps-the-nat-port-has-to-match-k6n</guid>
      <description>&lt;p&gt;&lt;em&gt;WireGuard is a few hundred lines of kernel code and two small config files, and the whole setup takes about fifteen minutes once you know which port to open. The part that actually trips people up on a budget VPS is not WireGuard at all — it is that the port you tell WireGuard to listen on has to be one the provider already forwards to you.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What WireGuard actually is
&lt;/h2&gt;

&lt;p&gt;WireGuard is not a service you install and forget about; it is a network interface. The kernel module has shipped in mainline Linux since version 5.6, so on Ubuntu 24.04 it is already part of the kernel you booted — there is no dkms build, no compiling against headers, nothing to break on a kernel update. What you install is &lt;code&gt;wireguard-tools&lt;/code&gt;, which gives you two commands: &lt;code&gt;wg&lt;/code&gt;, for keys and status, and &lt;code&gt;wg-quick&lt;/code&gt;, which reads a config file and brings the interface up or down as a single unit.&lt;/p&gt;

&lt;p&gt;That simplicity is also the constraint. There is no built-in dashboard, no user accounts, and no bandwidth graph. Every peer, on either end, is one public key and one line of config. Getting comfortable editing a text file by hand is the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read this before you buy: the NAT IPv4 port catch
&lt;/h2&gt;

&lt;p&gt;Cheap VPS plans very commonly give you &lt;strong&gt;NAT IPv4&lt;/strong&gt; — one shared public address with a small, fixed set of forwarded ports — rather than an address that is yours alone. That is fine for outbound connections, which is all a WireGuard &lt;em&gt;client&lt;/em&gt; ever makes. It matters the moment your VPS is the WireGuard &lt;em&gt;server&lt;/em&gt;, because a server has to accept an inbound connection on a specific UDP port, and behind NAT you cannot just pick &lt;code&gt;51820&lt;/code&gt; because a guide told you to.&lt;/p&gt;

&lt;p&gt;The rule is simple once you know it: &lt;code&gt;ListenPort&lt;/code&gt; in your server config must be set to one of the ports your provider actually forwards to your machine, and the client's &lt;code&gt;Endpoint&lt;/code&gt; must use that same port. Pick a random port instead and the tunnel will look correctly configured on both ends and simply never connect, because the packets never reach your VPS in the first place.&lt;/p&gt;

&lt;p&gt;Two ways through it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use one of the forwarded ports you already have.&lt;/strong&gt; WireGuard does not care what number it runs on, so point &lt;code&gt;ListenPort&lt;/code&gt; at whichever forwarded port is free and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a dedicated IPv4.&lt;/strong&gt; With an address of your own, any port is yours to open, including the WireGuard default. On our plans a dedicated IPv4 is available on request by e-mail, not as a self-service add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; and &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt; before you order if you are not sure which situation you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a clean machine
&lt;/h2&gt;

&lt;p&gt;Deploy an Ubuntu 24.04 LTS VPS, then do the boring security work before anything is exposed to the internet: a non-root user, your SSH key on it, password login switched off, and a firewall that defaults to deny. We walk through that in &lt;a href="https://overnight.host/help/connect-to-your-vps-over-ssh/" rel="noopener noreferrer"&gt;connect to your VPS over SSH&lt;/a&gt; and &lt;a href="https://overnight.host/help/secure-your-vps/" rel="noopener noreferrer"&gt;secure your VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Note the forwarded UDP port range or list from your provider's panel before you go further — you need an actual number for the next step, not a placeholder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: install WireGuard and generate keys
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; wireguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pulls in &lt;code&gt;wireguard-tools&lt;/code&gt;; the kernel side is already present. Installing it also creates &lt;code&gt;/etc/wireguard&lt;/code&gt; owned by root with mode &lt;code&gt;0700&lt;/code&gt;, so your regular sudo user cannot &lt;code&gt;cd&lt;/code&gt; into it or write a key there — generate the server's key pair as root, with a strict umask so the private key is never briefly world-readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /etc/wireguard
&lt;span class="nb"&gt;umask &lt;/span&gt;077
wg genkey | &lt;span class="nb"&gt;tee &lt;/span&gt;server_private.key | wg pubkey &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; server_public.key
&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the whole block in one root shell rather than prefixing each line with &lt;code&gt;sudo&lt;/code&gt; — &lt;code&gt;sudo&lt;/code&gt; does not inherit the &lt;code&gt;umask 077&lt;/code&gt; you just set, so a command like &lt;code&gt;sudo tee&lt;/code&gt; can still create the key file with its own default, more permissive mode even though the umask line ran.&lt;/p&gt;

&lt;p&gt;Generate a second pair for the client the same way, on whichever machine will hold it — it does not have to be the server. Keep the two private keys apart; only the matching public key ever leaves its own side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the server config, routing and NAT
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/wireguard/wg0.conf&lt;/code&gt;, using one of your forwarded ports for &lt;code&gt;ListenPort&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Interface]&lt;/span&gt;
&lt;span class="py"&gt;PrivateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;server_private.key contents&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.66.66.1/24&lt;/span&gt;
&lt;span class="py"&gt;ListenPort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;41194&lt;/span&gt;
&lt;span class="py"&gt;PostUp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;iptables -t nat -A POSTROUTING -o &amp;lt;WAN_INTERFACE&amp;gt; -j MASQUERADE; iptables -A FORWARD -i wg0 -j ACCEPT&lt;/span&gt;
&lt;span class="py"&gt;PostDown&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;iptables -t nat -D POSTROUTING -o &amp;lt;WAN_INTERFACE&amp;gt; -j MASQUERADE; iptables -D FORWARD -i wg0 -j ACCEPT&lt;/span&gt;

&lt;span class="nn"&gt;[Peer]&lt;/span&gt;
&lt;span class="py"&gt;PublicKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;client public key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;AllowedIPs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.66.66.2/32&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;&amp;lt;WAN_INTERFACE&amp;gt;&lt;/code&gt; with your real outbound interface name, not &lt;code&gt;eth0&lt;/code&gt;. Find it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-brief&lt;/span&gt; &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the thing that bites later: most published examples hardcode &lt;code&gt;eth0&lt;/code&gt;, but plenty of KVM images come up as &lt;code&gt;ens3&lt;/code&gt;, &lt;code&gt;enp1s0&lt;/code&gt; or similar. Get the name wrong and the tunnel connects fine, &lt;code&gt;wg show&lt;/code&gt; looks healthy, and the client still has no internet through it, because the MASQUERADE rule silently applies to an interface that does not exist.&lt;/p&gt;

&lt;p&gt;Turn on IP forwarding and make it survive a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'net.ipv4.ip_forward=1'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-wireguard.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: firewall, and the port that has to match
&lt;/h2&gt;

&lt;p&gt;Open the same forwarded port, over UDP, and nothing else new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 41194/udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WireGuard is UDP end to end. A rule that opens the port as TCP by mistake produces the same symptom as picking a non-forwarded port: everything looks configured and nothing arrives. If you change the listen port later, update it in three places at once — the config file, the firewall rule, and every client's &lt;code&gt;Endpoint&lt;/code&gt; — or you will spend ten minutes debugging a mismatch you made yourself five minutes earlier.&lt;/p&gt;

&lt;p&gt;If you followed Step 1's advice and have &lt;code&gt;ufw&lt;/code&gt; running, there is a second gate to check: &lt;code&gt;ufw&lt;/code&gt; ships with &lt;code&gt;DEFAULT_FORWARD_POLICY="DROP"&lt;/code&gt; in &lt;code&gt;/etc/default/ufw&lt;/code&gt;, which blocks exactly the routed, NAT'd traffic this tunnel exists to move, regardless of the port being open. Edit that file, set &lt;code&gt;DEFAULT_FORWARD_POLICY="ACCEPT"&lt;/code&gt;, and run &lt;code&gt;sudo ufw reload&lt;/code&gt; before you test a client — leave it on &lt;code&gt;DROP&lt;/code&gt; and the handshake still completes and &lt;code&gt;wg show&lt;/code&gt; still looks healthy, but the client has no internet through the tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: one client config
&lt;/h2&gt;

&lt;p&gt;This is the entire client side, for the official WireGuard app on any platform or for &lt;code&gt;wg-quick&lt;/code&gt; on another Linux box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Interface]&lt;/span&gt;
&lt;span class="py"&gt;PrivateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;client private key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.66.66.2/32&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;

&lt;span class="nn"&gt;[Peer]&lt;/span&gt;
&lt;span class="py"&gt;PublicKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;server_public.key contents&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;203.0.113.10:41194&lt;/span&gt;
&lt;span class="py"&gt;AllowedIPs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
&lt;span class="py"&gt;PersistentKeepalive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;203.0.113.10&lt;/code&gt; with your VPS's public NAT IPv4 address and &lt;code&gt;41194&lt;/code&gt; with the forwarded port from Step 3. &lt;code&gt;AllowedIPs = 0.0.0.0/0&lt;/code&gt; routes all of the client's traffic through the tunnel, which is what most people mean by "my own VPN" — narrow it to specific subnets if you only want the tunnel for part of your traffic. &lt;code&gt;PersistentKeepalive = 25&lt;/code&gt; matters specifically because the client usually sits behind its own NAT (a home router, a phone on mobile data); without it, that NAT's connection tracking entry expires during idle periods and the server cannot reach the client until it sends something first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it running
&lt;/h2&gt;

&lt;p&gt;Bring the interface up and enable it for every future boot in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; wg-quick@wg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it with &lt;code&gt;sudo wg show&lt;/code&gt;, which lists the peer, the last handshake time and the bytes moved in each direction — a peer with no handshake ever recorded means the packets are not arriving, which sends you back to Step 3's interface name or Step 4's port. &lt;code&gt;sudo systemctl status wg-quick@wg0&lt;/code&gt; and &lt;code&gt;journalctl -u wg-quick@wg0&lt;/code&gt; cover the rest.&lt;/p&gt;

&lt;p&gt;There is no key rotation schedule to maintain and no update service running that needs restarting on a cadence — WireGuard's attack surface is deliberately small. The two things actually worth a recurring check: that &lt;code&gt;wireguard-tools&lt;/code&gt; picks up routine security updates with the rest of the system (&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt;), and that nothing in your provider's panel changes which ports are forwarded to you, since that is the one setting this whole guide hangs off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a VPN in Dallas or Charlotte does and does not hide
&lt;/h2&gt;

&lt;p&gt;Our machines run in Dallas, TX and Charlotte, NC, so a WireGuard server there gives you a US exit address — sites you visit see that VPS's IP, not your home or mobile connection's. Inside the tunnel, your local network operator, your ISP and anyone else between you and the VPS sees only encrypted WireGuard packets to one UDP port; they cannot read what is inside them or which sites you are actually visiting.&lt;/p&gt;

&lt;p&gt;What it does not do is anonymise you. Any site you log into still recognises you as you, regardless of which IP address you arrive from. Browser fingerprinting is unaffected — cookies, canvas fingerprinting and account sessions do not care what your network path looks like. Your traffic's final destination is also visible to the operator of that VPS, because unlike a multi-hop system such as Tor, this is a single hop: your ISP no longer sees your browsing, but something has to terminate the tunnel and see the traffic in cleartext before it goes onward, and that something is your own VPS. Geolocation-wise, expect exactly one change: sites that check IP location will place you in Texas or North Carolina, and a few services that treat datacentre address ranges differently from residential ones may notice that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. A 1 vCPU, 1 GiB Starter runs a personal WireGuard tunnel without noticing it; move up a tier only once you are pushing serious throughput or running several always-on tunnels off the same box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux KVM VPS&lt;/strong&gt; — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/vps-starter" rel="noopener noreferrer"&gt;Order vps-starter →&lt;/a&gt; · &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;Linux KVM VPS overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a dedicated IPv4 to run WireGuard?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. NAT IPv4 works fine as a WireGuard server as long as &lt;code&gt;ListenPort&lt;/code&gt; in your config is one of the ports your provider forwards to you, and every client's &lt;code&gt;Endpoint&lt;/code&gt; uses that same port. A dedicated IPv4 only becomes useful if you want to run more services than you have forwarded ports for, or want the default port specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my tunnel show as configured but nothing connects?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The two most common causes are a &lt;code&gt;ListenPort&lt;/code&gt; that is not actually forwarded to your VPS, and a firewall rule opened as TCP instead of UDP. Check &lt;code&gt;sudo wg show&lt;/code&gt; for a peer with no recorded handshake — that confirms packets are not arriving at all, which points at the port rather than at routing or keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run more than one client?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Add one &lt;code&gt;[Peer]&lt;/code&gt; block per client to the server config, each with its own public key and its own &lt;code&gt;/32&lt;/code&gt; address inside your chosen subnet, then reload with &lt;code&gt;sudo wg syncconf wg0 &amp;lt;(wg-quick strip wg0)&lt;/code&gt; or a restart of the service. Every client keeps its own private key; only public keys go on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does WireGuard hide what I do from my VPS provider?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The VPS is where your tunnel ends and your traffic goes back onto the open internet in cleartext, so whoever controls that machine could, in principle, see it — the same is true of any VPS you use for anything. What the tunnel does hide is your traffic from your local network and your ISP between your device and that VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does WireGuard cost to run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing beyond the VPS itself. It is a kernel feature and a command-line tool, both free, with no license and no per-peer fee. A personal tunnel for one or two people idles well inside a 1 GiB Starter; sizing up only matters once you are pushing meaningful throughput through it continuously.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/wireguard-vpn-on-a-vps/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; — the canonical, kept-current version of this guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wireguard</category>
      <category>vpn</category>
      <category>linux</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Self-hosting Nextcloud properly: storage, cron, and the settings everyone gets wrong</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Sat, 12 Sep 2026 07:18:23 +0000</pubDate>
      <link>https://dev.to/overnighthost/self-hosting-nextcloud-properly-storage-cron-and-the-settings-everyone-gets-wrong-1dbd</link>
      <guid>https://dev.to/overnighthost/self-hosting-nextcloud-properly-storage-cron-and-the-settings-everyone-gets-wrong-1dbd</guid>
      <description>&lt;p&gt;&lt;em&gt;Nextcloud installs in ten minutes and then spends two years being mysteriously slow. Almost all of it comes down to four settings and one decision about where the files live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide the shape first
&lt;/h2&gt;

&lt;p&gt;There are three sane ways to run it, and picking the wrong one is the expensive mistake.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nextcloud AIO&lt;/strong&gt; — an all-in-one master container that manages the rest for you. Good defaults, handles backups and updates, and it wants the Docker socket and a port of its own. Easiest if you want opinions rather than knobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain Docker Compose&lt;/strong&gt; — Nextcloud, a database and Redis as three services you own. More work, total control, and the shape used below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A managed instance&lt;/strong&gt; — someone else's problem entirely. Fine choice if the point was the files, not the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is &lt;em&gt;not&lt;/em&gt; a sane way: SQLite. Nextcloud will let you, and it works right up until two devices sync at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing: it is a disk problem, not a CPU problem
&lt;/h2&gt;

&lt;p&gt;Nextcloud is a file server with a web UI. CPU matters for thumbnail generation and the occasional big scan; the thing that actually runs out is &lt;strong&gt;disk&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Budget for what you will store in two years, not what you have today, and remember the multiplier: a file that exists on your laptop, your phone and the server exists three times, and versioning keeps old copies of anything you edit. &lt;strong&gt;2 GiB of RAM&lt;/strong&gt; is a comfortable floor for a small household instance with Redis alongside; the database is small unless you have hundreds of thousands of files.&lt;/p&gt;

&lt;p&gt;If the disk is the constraint, plan how you grow it before you fill it — &lt;a href="https://overnight.host/help/add-extra-storage-to-your-vps/" rel="noopener noreferrer"&gt;add extra storage to your VPS&lt;/a&gt; covers the mechanics on our machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb:11.4&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_DATABASE=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_USER=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MARIADB_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db:/var/lib/mysql&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud:30-apache&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_HOST=db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_HOST=redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OVERWRITEPROTOCOL=https&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PHP_MEMORY_LIMIT=512M&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PHP_UPLOAD_LIMIT=10G&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;

  &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud:30-apache&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/cron.sh&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nextcloud&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth service is not optional, and it is the one most guides leave out. Read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four settings everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;trusted_domains&lt;/code&gt;.&lt;/strong&gt; Nextcloud refuses any hostname it was not told about, with a blunt "access through untrusted domain" page. Set it at install (&lt;code&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/code&gt; above) or fix it later in &lt;code&gt;config/config.php&lt;/code&gt;. Add every name you will really use, including the one you use from inside your own network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;overwriteprotocol&lt;/code&gt;.&lt;/strong&gt; Behind a reverse proxy that terminates TLS, PHP sees plain HTTP and starts generating &lt;code&gt;http://&lt;/code&gt; links inside an &lt;code&gt;https://&lt;/code&gt; page. Browsers block them and the UI half-breaks. &lt;code&gt;OVERWRITEPROTOCOL=https&lt;/code&gt; fixes it. If your proxy is on a different host, set &lt;code&gt;trusted_proxies&lt;/code&gt; too, or every login shows up in the logs from the proxy's address instead of the user's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Background jobs.&lt;/strong&gt; A default install runs its background jobs via AJAX, meaning "when somebody happens to load a page". File scans, previews, cleanup and notifications then run late or never, and the instance feels slower every month. The &lt;code&gt;cron&lt;/code&gt; service above is the fix: the same image with &lt;code&gt;/cron.sh&lt;/code&gt; as the entrypoint, sharing the volume. Then set the method to Cron in &lt;strong&gt;Administration → Basic settings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Upload limits.&lt;/strong&gt; They are enforced in three places and the smallest wins: PHP (&lt;code&gt;PHP_UPLOAD_LIMIT&lt;/code&gt;), the reverse proxy body size, and any intermediate CDN. Raising one and forgetting the others is why "it fails at exactly 512 MB".&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS and the proxy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cloud.example.com {
    reverse_proxy 127.0.0.1:8080
    request_body {
        max_size 10GB
    }
    header {
        Strict-Transport-Security "max-age=31536000;"
    }
    redir /.well-known/carddav /remote.php/dav 301
    redir /.well-known/caldav /remote.php/dav 301
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two redirects are what silence the "your web server is not properly set up to resolve /.well-known/caldav" warning, and without them contact and calendar sync in some clients simply does not find the server. Point the DNS record first — &lt;a href="https://overnight.host/help/point-your-domain-to-your-service/" rel="noopener noreferrer"&gt;point your domain at your service&lt;/a&gt; if you have not done that before.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the install: run the checks
&lt;/h2&gt;

&lt;p&gt;The admin overview page has a security &amp;amp; setup warnings section. Do not treat it as decoration; it is a genuine checklist. The two that matter most on a fresh install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data app php occ db:add-missing-indices
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data app php occ db:add-missing-primary-keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nextcloud ships new indices with new versions but will not add them to a live database on its own, and a table without them gets slow in a way that looks like "the server is underpowered".&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups
&lt;/h2&gt;

&lt;p&gt;Three things, and you need all three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the &lt;strong&gt;database&lt;/strong&gt; — &lt;code&gt;mysqldump&lt;/code&gt; (or &lt;code&gt;mariadb-dump&lt;/code&gt;) of the &lt;code&gt;nextcloud&lt;/code&gt; database;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;data directory&lt;/strong&gt; — the files themselves, which is the big one;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;config/config.php&lt;/code&gt;&lt;/strong&gt; — small, and irritating to reconstruct.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put the instance into maintenance mode first so the dump and the files agree with each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data app php occ maintenance:mode &lt;span class="nt"&gt;--on&lt;/span&gt;
&lt;span class="c"&gt;# dump the database, copy the data directory&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data app php occ maintenance:mode &lt;span class="nt"&gt;--off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the result off the machine. Nothing about self-hosting includes a backup by default — that part is yours to arrange, on any provider. &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; is our own write-up of what that means in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. If you want the app without the sysadmin, we run Nextcloud as a managed container with its own volume and hostname — you get the app and a URL, not a root shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click apps&lt;/strong&gt; — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/one-click-nextcloud" rel="noopener noreferrer"&gt;Order one-click-nextcloud →&lt;/a&gt; · &lt;a href="https://overnight.host/automation/" rel="noopener noreferrer"&gt;One-click apps overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How much disk does Nextcloud need?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As much as the files, plus room for file versions and the trash, plus the previews it generates. Size for what you expect in two years rather than what you have now, and check the versioning and retention settings early — they are the quiet consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use SQLite?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only for a look-around. Nextcloud supports it, but it serialises writes and falls over as soon as two clients sync at the same time. MariaDB or PostgreSQL for anything you intend to keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why are my uploads failing at a fixed size?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Something in the chain has a body-size limit smaller than the file. Check PHP's upload and post limits, the reverse proxy's max body size, and any CDN in front. The smallest number wins, and it is usually the proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Strongly recommended. Nextcloud uses it for transactional file locking and caching; without it you eventually meet the "file is locked" errors under concurrent sync. It costs a few megabytes of RAM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a managed Nextcloud different from self-hosting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Functionally it is the same application. The difference is who holds the root shell: with a managed container you get the app, a hostname and a certificate, and you do not get to install PHP extensions or run &lt;code&gt;occ&lt;/code&gt; yourself. If you need that control, put it on a VPS instead.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://overnight.host/guides/self-host-nextcloud-properly/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; — the canonical, kept-current version of this guide.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextcloud</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Self-hosting Vaultwarden on a VPS: HTTPS, the admin token, and the NAT catch</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Fri, 11 Sep 2026 23:12:35 +0000</pubDate>
      <link>https://dev.to/overnighthost/self-hosting-vaultwarden-on-a-vps-https-the-admin-token-and-the-nat-catch-5105</link>
      <guid>https://dev.to/overnighthost/self-hosting-vaultwarden-on-a-vps-https-the-admin-token-and-the-nat-catch-5105</guid>
      <description>&lt;p&gt;&lt;em&gt;A password vault is the one self-hosted app where "mostly working" is not good enough. Vaultwarden is small, fast on a fraction of a gigabyte, and every serious client refuses to talk to it without a real certificate — which is the part that actually takes the twenty minutes.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vaultwarden is, and is not
&lt;/h2&gt;

&lt;p&gt;Vaultwarden is a from-scratch server implementation of the Bitwarden sync API, written in Rust, distributed as a single small Docker image. It is not Bitwarden's own server software (that project is the much heavier &lt;code&gt;bitwarden/server&lt;/code&gt; stack) and it is not affiliated with Bitwarden the company — it is a community project that happens to speak the same protocol, so the official Bitwarden apps and browser extensions work against it once you point them at your own server URL. That compatibility is the entire pitch: your phone, laptop and browser extension do not know or care that the server on the other end is Vaultwarden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HTTPS is not optional here
&lt;/h2&gt;

&lt;p&gt;Every other app in this series can limp along on plain HTTP for testing. Vaultwarden cannot, and the reason is not policy, it is the client software. The web vault runs inside your browser, and once that page is loaded over HTTPS, mixed-content rules block it from talking to a plain &lt;code&gt;http://&lt;/code&gt; server — the browser refuses the request before it leaves the page. The mobile app and browser extension add their own validation on top and reject a non-HTTPS, non-localhost server URL outright, regardless of what the browser would allow. Try to add a plain-&lt;code&gt;http://&lt;/code&gt; server URL anywhere but &lt;code&gt;localhost&lt;/code&gt; and it gets refused before it ever reaches your container. There is no setting that turns this off, because it is not Vaultwarden's rule to relax.&lt;/p&gt;

&lt;p&gt;Practically this means: get a real hostname and a real certificate before you try to log in from a client, not after. A self-signed certificate does not fix it either — the clients want something a normal trust store accepts, so a real domain behind Let's Encrypt (or equivalent) is the actual requirement, not an optional nicety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read this before you buy: the NAT IPv4 catch
&lt;/h2&gt;

&lt;p&gt;Cheap VPS plans very often hand you &lt;strong&gt;NAT IPv4&lt;/strong&gt; — one shared public address with a short list of forwarded ports — rather than an address that is entirely yours. That is fine for outbound traffic and for SSH on a non-standard port, but it changes how you expose HTTPS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;443 is one of your forwarded ports&lt;/strong&gt;, point your domain at the VPS, run Caddy on 443 as below, and everything in this guide works exactly as written.&lt;/li&gt;
&lt;li&gt;If it is &lt;strong&gt;not&lt;/strong&gt;, you cannot bind the standard HTTPS port, so the honest fix is to run Caddy on whatever port you were given and put that port in the client's &lt;strong&gt;server URL&lt;/strong&gt; — &lt;code&gt;https://vault.example.com:8443&lt;/code&gt;, say. Every Bitwarden client accepts a port in the server URL field; it is one extra field at setup, not a limitation of the protocol. This only works if &lt;strong&gt;port 80 is also forwarded to you&lt;/strong&gt;: Caddy serves the site itself on 8443, but still needs 80 free to complete the HTTP-01 challenge that gets the certificate from Let's Encrypt in the first place. If 80 is not forwarded either, automatic HTTPS on a non-standard port needs a DNS-01 challenge instead — a Caddy build with your DNS provider's plugin, which the stock image does not ship, plus API credentials — so ask about the dedicated IPv4 below if that is on the table.&lt;/li&gt;
&lt;li&gt;The third option is a &lt;strong&gt;dedicated IPv4&lt;/strong&gt;, which gives you 443 outright. On our plans that is available on request by e-mail, not as a self-service add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth reading first: &lt;a href="https://overnight.host/guides/nat-ipv4-vs-dedicated-ip/" rel="noopener noreferrer"&gt;NAT IPv4 vs a dedicated IP&lt;/a&gt; and &lt;a href="https://overnight.host/help/nat-ipv4-ports-and-forwarding/" rel="noopener noreferrer"&gt;NAT IPv4, ports and forwarding&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file
&lt;/h2&gt;

&lt;p&gt;Pin the image tag. Vaultwarden ships new releases regularly and some of them change the database schema, so &lt;code&gt;latest&lt;/code&gt; on a vault you actually rely on is a bad trade for saving one line of maintenance. Check the current release at the project's GitHub releases page before copying the tag below — this guide will drift out of date, and running whatever is current matters more than this exact number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vaultwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden/server:1.32.7&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOMAIN=https://vault.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SIGNUPS_ALLOWED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ADMIN_TOKEN=${ADMIN_TOKEN}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WEBSOCKET_ENABLED=true&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vw-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vw-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port is bound to &lt;code&gt;127.0.0.1&lt;/code&gt; on purpose: nothing but the reverse proxy on the same host can reach Vaultwarden directly, so a stray scanner hitting the VPS's public address on port 8080 finds nothing.&lt;/p&gt;

&lt;p&gt;Generate the admin token before first boot, not after, then hash it rather than storing it raw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; vaultwarden/server:1.32.7 /vaultwarden &lt;span class="nb"&gt;hash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prompts for a password — paste the output of &lt;code&gt;openssl rand -base64 48&lt;/code&gt; into it — and prints an Argon2 PHC string. Put that string, not the raw random value, into &lt;code&gt;.env&lt;/code&gt;, wrapped in single quotes — an Argon2 string is full of &lt;code&gt;$&lt;/code&gt; signs, and Docker Compose would otherwise try to expand them as variables and hand Vaultwarden a mangled token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"ADMIN_TOKEN='%s'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'paste-the-argon2-phc-string-here'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vaultwarden accepts a plain token too, but the hashed form is the one its own documentation recommends — it is not vulnerable to timing-attack recovery from the &lt;code&gt;/admin&lt;/code&gt; login the way a raw comparison is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signups, off by default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt; matters more here than the equivalent setting does on most self-hosted apps, because a vault is the one place a stranger creating their own account is a genuinely bad outcome, not just clutter. Leave signups closed and create your own accounts through the &lt;code&gt;/admin&lt;/code&gt; page instead, using the token above. If you do want a second household member in, invite them from an existing account rather than reopening signups — invitations work with &lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt; set.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/admin&lt;/code&gt; page itself is worth locking down further once you are done using it day to day: it accepts the same token forever unless you rotate it, so treat that token like a root password, not like an application setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS with Caddy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vault.example.com {
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole file — Caddy requests and renews the certificate on its own the first time it sees a request for that hostname, provided DNS already points at the machine. Point the &lt;code&gt;A&lt;/code&gt; record first and let it settle; &lt;a href="https://overnight.host/help/point-your-domain-to-your-service/" rel="noopener noreferrer"&gt;point your domain at your service&lt;/a&gt; covers that if you have not done it before. If you are on the forwarded-port path from the NAT section above, change the site block to &lt;code&gt;vault.example.com:8443&lt;/code&gt;, open that port, and make sure 80 is forwarded too — Caddy serves the site on 8443 but still needs 80 open to fetch and renew the certificate itself.&lt;/p&gt;

&lt;p&gt;WebSockets need to pass through cleanly for live sync between devices to work — Caddy's &lt;code&gt;reverse_proxy&lt;/code&gt; handles the &lt;code&gt;Upgrade&lt;/code&gt;/&lt;code&gt;Connection&lt;/code&gt; headers automatically, which is one less thing to get wrong compared with a manually written nginx config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing: this one is genuinely small
&lt;/h2&gt;

&lt;p&gt;Vaultwarden is a single Rust binary and a SQLite file holding encrypted blobs — there is no PHP runtime, no Node process, no in-memory cache to budget for. &lt;strong&gt;1 GiB of RAM is plenty&lt;/strong&gt;, including room for Caddy and the OS itself, even with a handful of accounts and a few hundred items each. Disk is dominated by attachments, if you use them, and those are also usually small. This is the rare self-hosted app where the constraint is not sizing at all — it is getting HTTPS right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups: three things, not one
&lt;/h2&gt;

&lt;p&gt;The data volume holds more than a database, and all of it needs to leave the machine together.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;SQLite database&lt;/strong&gt; (&lt;code&gt;db.sqlite3&lt;/code&gt;) — accounts, encrypted vault items, organizations;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;attachments&lt;/strong&gt;, stored as files under the same data directory;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;RSA key pair&lt;/strong&gt; (&lt;code&gt;rsa_key.pem&lt;/code&gt;, &lt;code&gt;rsa_key.pub.pem&lt;/code&gt;) that Vaultwarden generates on first boot and uses to sign authentication tokens — lose it and every existing client session invalidates, forcing a re-login everywhere, though the vault data itself is still readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three live under &lt;code&gt;/data&lt;/code&gt; inside the container, so a volume-level copy catches everything in one pass — but stop the container first. &lt;code&gt;db.sqlite3&lt;/code&gt; is a live database while Vaultwarden runs, and a raw copy taken mid-write can land on a torn, inconsistent snapshot; a few seconds of downtime is cheap insurance against a backup that turns out useless the day you need it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose stop vaultwarden
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; vw-data:/data:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/backup &lt;span class="se"&gt;\&lt;/span&gt;
  alpine:3.20 &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/vaultwarden-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
docker compose start vaultwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a schedule and copy the result off the VPS entirely — a tarball sitting next to the volume it came from does not survive the disk it is on failing. &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; is our own write-up of what off-machine backup actually means in practice, on any provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump the pinned tag deliberately rather than tracking &lt;code&gt;latest&lt;/code&gt;, and skim the release notes first — Vaultwarden documents schema migrations and any breaking environment-variable changes per release, and they run automatically against your SQLite file on the next start. Take the backup above immediately before an update on a vault you cannot afford to lose; a failed migration is rare, but a vault is exactly the file you do not want to find that out about the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. If you want the app without the sysadmin, the managed Vaultwarden container comes with its own hostname and certificate — you get the app and a URL, not a root shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click apps&lt;/strong&gt; — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/one-click-vaultwarden" rel="noopener noreferrer"&gt;Order one-click-vaultwarden →&lt;/a&gt; · &lt;a href="https://overnight.host/automation/" rel="noopener noreferrer"&gt;One-click apps overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use Vaultwarden over plain HTTP for testing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only from &lt;code&gt;localhost&lt;/code&gt;, because that is the one address every Bitwarden client also treats as trusted. Anything reached over a network — your phone, a different machine, even another device on your own LAN — needs real HTTPS before the clients will accept the server URL: the web vault is blocked by its own browser's mixed-content rules, and the mobile app and extension refuse a non-HTTPS, non-localhost URL by their own validation regardless of what the browser would allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a dedicated IPv4?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. A forwarded port for 443 works fine, and if you were not given one, running HTTPS on a non-standard port and putting that port in the client's server URL works exactly as well. A dedicated IPv4 is only for when you want the standard port without any of that, and on our plans it is arranged by e-mail rather than self-service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Vaultwarden as secure as Bitwarden's own server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It implements the same client-side encryption model — your vault is encrypted and decrypted on your device, and the server only ever stores ciphertext — so a compromise of the server does not hand over readable vault contents either way. What differs is who wrote and maintains the server code: Vaultwarden is an independent, community-maintained reimplementation, not Bitwarden's own software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I lose the RSA key pair?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every client gets logged out and has to re-authenticate, because the tokens they were holding were signed with a key that no longer exists. Your vault data is unaffected — it is encrypted with your own master password, not with that key pair — but it is still a bad afternoon, which is why the key pair is one of the three things backed up above, not an afterthought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I turn signups back on later for one more person?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You do not need to. Existing accounts can send invitations while &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; stays &lt;code&gt;false&lt;/code&gt;, so a new household member gets in without the vault being open to anyone who finds the URL in between.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vaultwarden</category>
      <category>bitwarden</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>Running a trading bot on a Linux VPS 24/7: systemd, chrony, and a kill switch you've actually tested</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Fri, 11 Sep 2026 23:10:19 +0000</pubDate>
      <link>https://dev.to/overnighthost/running-a-trading-bot-on-a-linux-vps-247-systemd-chrony-and-a-kill-switch-youve-actually-tested-4ngb</link>
      <guid>https://dev.to/overnighthost/running-a-trading-bot-on-a-linux-vps-247-systemd-chrony-and-a-kill-switch-youve-actually-tested-4ngb</guid>
      <description>&lt;p&gt;&lt;em&gt;A trading bot that only runs when your laptop is open is not a trading bot, it is a demo. Getting it onto a server that stays up is the easy part; the part that actually matters is boring operations work — restart policy, clock sync, and knowing the bot is still alive without staring at a terminal.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this covers, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;This is an operations guide: running a Python trading bot as a long-lived Linux process that restarts sanely, keeps credentials off disk in the wrong place, agrees with the exchange about what time it is, and tells you when it dies. It does not cover strategy, order types, backtesting, or position sizing — that is your call to make, and nothing here is financial advice. Assume you already have a working bot that talks to an exchange's API and just need it to run unattended without becoming a liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing the machine
&lt;/h2&gt;

&lt;p&gt;A single bot process is a light workload. A Python process holding a websocket connection, a small in-memory order book, and a logging pipeline typically sits well under &lt;strong&gt;300 MB&lt;/strong&gt; resident. The &lt;strong&gt;1 GiB Starter tier&lt;/strong&gt; covers one bot with headroom for the OS and chrony. What actually pushes you up a tier is running several bots on one box, keeping a local database of fills and candles that grows for months, or backtesting on the same machine you trade from — batch work and a live process fight over the same cores. Keep those separate if you can.&lt;/p&gt;

&lt;p&gt;Disk is rarely the constraint: a bot's code, a virtualenv, and months of rotated JSON logs fit inside a few gigabytes. The &lt;strong&gt;25 GB&lt;/strong&gt; on Starter is plenty unless you're recording full market data ticks, a different sizing conversation entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a clean, outbound-only machine
&lt;/h2&gt;

&lt;p&gt;Deploy an &lt;a href="https://overnight.host/ubuntu-vps/" rel="noopener noreferrer"&gt;Ubuntu LTS image&lt;/a&gt;, then do the basics before anything touches the network: a non-root user, an SSH key, password login off, and a firewall that defaults to deny. Walkthroughs for both are at &lt;a href="https://overnight.host/help/connect-to-your-vps-over-ssh/" rel="noopener noreferrer"&gt;connect to your VPS over SSH&lt;/a&gt; and &lt;a href="https://overnight.host/help/secure-your-vps/" rel="noopener noreferrer"&gt;secure your VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This workload only calls out — REST calls and a websocket to the exchange, nothing listening for inbound connections — so the shared, NAT-style IPv4 most budget VPS plans hand out by default is irrelevant here. No dedicated address or forwarded port needed for anything in this guide.&lt;/p&gt;

&lt;p&gt;Create a dedicated system user for the bot rather than running it as your own login or as root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin botuser
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/trading-bot/data
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; botuser:botuser /opt/trading-bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy your code under &lt;code&gt;/opt/trading-bot&lt;/code&gt; and build the virtualenv as &lt;code&gt;botuser&lt;/code&gt;, not as yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; botuser python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv /opt/trading-bot/venv
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; botuser /opt/trading-bot/venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /opt/trading-bot/requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: the bot as a systemd service
&lt;/h2&gt;

&lt;p&gt;A bot running in a &lt;code&gt;tmux&lt;/code&gt; session survives an SSH disconnect but not a reboot, a crash, or you forgetting which pane it's in. A systemd unit survives all three and gives you a restart policy for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;trading-bot&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target chrony.service&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;botuser&lt;/span&gt;
&lt;span class="py"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;botuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/trading-bot&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/trading-bot/env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/trading-bot/venv/bin/python /opt/trading-bot/bot.py&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;NoNewPrivileges&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;ProtectSystem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;
&lt;span class="py"&gt;ReadWritePaths&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/trading-bot/data&lt;/span&gt;
&lt;span class="py"&gt;PrivateTmp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two choices here are deliberate. &lt;code&gt;StartLimitIntervalSec=300&lt;/code&gt; with &lt;code&gt;StartLimitBurst=5&lt;/code&gt; stops a crash loop from hammering the exchange's API forever — after five restarts in five minutes, systemd gives up and leaves the unit stopped instead of retrying into a rate-limit ban. And &lt;code&gt;Restart=on-failure&lt;/code&gt;, not &lt;code&gt;Restart=always&lt;/code&gt;, only restarts the process on a non-zero exit status. That distinction is the whole point of the kill switch in Step 7, so hold onto it.&lt;/p&gt;

&lt;p&gt;Enable it now. Don't start it yet — the unit's &lt;code&gt;EnvironmentFile&lt;/code&gt; doesn't exist until Step 3, and systemd refuses to start a service whose EnvironmentFile is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;trading-bot.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html" rel="noopener noreferrer"&gt;systemd.service manual&lt;/a&gt; documents every directive above if you want the exact semantics rather than my summary of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: API keys in a root-only file, never in the repo
&lt;/h2&gt;

&lt;p&gt;The exchange API key and secret don't belong in your code, in a &lt;code&gt;.env&lt;/code&gt; committed by accident, or in a config file readable by every user on the box. They belong in an &lt;code&gt;EnvironmentFile&lt;/code&gt; only root can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/trading-bot
&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /etc/trading-bot/env
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/trading-bot/env
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/trading-bot/env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXCHANGE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-key-here
&lt;span class="nv"&gt;EXCHANGE_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-secret-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works even though the service runs as the unprivileged &lt;code&gt;botuser&lt;/code&gt;: systemd, running as root, reads &lt;code&gt;EnvironmentFile&lt;/code&gt; before dropping privileges to start your process, and hands the resulting variables to the already-running process. The file itself never needs to be readable by &lt;code&gt;botuser&lt;/code&gt;, so &lt;code&gt;chmod 600&lt;/code&gt; owned by &lt;code&gt;root&lt;/code&gt; is the correct, final permission — not a temporary one you loosen later because something "couldn't read it."&lt;/p&gt;

&lt;p&gt;Add the file's path to &lt;code&gt;.gitignore&lt;/code&gt; before you write anything into it, and check with &lt;code&gt;git status&lt;/code&gt; that it never shows up as untracked or staged. A key that reaches a private repo is still a key that reached a repo; rotate it if you're ever unsure.&lt;/p&gt;

&lt;p&gt;Now that &lt;code&gt;/etc/trading-bot/env&lt;/code&gt; exists, start the service you enabled in Step 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start trading-bot.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status trading-bot.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: clock sync with chrony
&lt;/h2&gt;

&lt;p&gt;Most exchange APIs sign requests with a timestamp and reject anything outside a tolerance window, often a few seconds. A drifted VPS clock gets &lt;code&gt;-1021&lt;/code&gt;-style "timestamp outside recvWindow" errors on every signed call — it looks exactly like a broken bot and is actually a broken clock.&lt;/p&gt;

&lt;p&gt;Ubuntu 24.04 ships &lt;code&gt;systemd-timesyncd&lt;/code&gt; by default, which is fine for most things but coarser than you want here. Chrony is the better fit for a machine that needs a tight, actively-monitored offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; chrony
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-timesyncd
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; chrony
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it's actually tracking, not just running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chronyc tracking
chronyc sources &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chronyc tracking&lt;/code&gt; should show a &lt;code&gt;System time&lt;/code&gt; offset in the low milliseconds within a minute or two of starting. If it stays wide, check outbound UDP 123 isn't blocked by your firewall rules. The &lt;a href="https://chrony-project.org/documentation.html" rel="noopener noreferrer"&gt;chrony documentation&lt;/a&gt; covers tuning &lt;code&gt;makestep&lt;/code&gt; and source selection if the defaults don't converge fast enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: structured logging you can actually grep at 3am
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;print()&lt;/code&gt; statements are fine for development and useless at 3am when you need the one line that explains why an order didn't go through. Log as JSON, one object per line, and let &lt;code&gt;journald&lt;/code&gt; capture stdout — no separate log file needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JsonFormatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Formatter&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%dT%H:%M:%SZ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gmtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;levelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;msg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exc_info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exc_info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StreamHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFormatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JsonFormatter&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because systemd already timestamps everything it captures, ask &lt;code&gt;journalctl&lt;/code&gt; for the raw JSON without its own prefix when you want to pipe it somewhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; trading-bot.service &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;journalctl&lt;/code&gt; rotates and caps its own storage by default, so you're not manually managing log files. If you add a second destination — a file, a shipper off-box — cap its size explicitly; an unbounded log file is a slow, quiet way to run out of disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: a heartbeat so a dead bot doesn't stay dead
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;systemctl status&lt;/code&gt; tells you the process is running, not that it's doing anything useful — a bot can be alive, connected, and stuck in a loop that stopped placing orders hours ago, and &lt;code&gt;Restart=on-failure&lt;/code&gt; never fires because nothing crashed.&lt;/p&gt;

&lt;p&gt;A dead man's switch fixes this, and it's a natural fit for an outbound-only bot: at the end of every successful loop, ping a monitoring URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_heartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# a missed heartbeat should never crash the bot
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;send_heartbeat()&lt;/code&gt; once per loop, after the real work succeeds, not before it. Pair it with a service that expects a ping on a schedule and pages you when one doesn't arrive — healthchecks.io is the standard example, and it's open source if you'd rather run it yourself. The mechanism matters more than the service: silence is the alert, which is exactly what catches a bot that's technically running but has quietly stopped doing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: test the kill switch before you need it
&lt;/h2&gt;

&lt;p&gt;Every bot needs a way to stop trading immediately, without SSH access being the only lever. The simplest version is a file the bot checks each loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;HALT_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/trading-bot/HALT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_halt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;HALT_FILE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;should_halt()&lt;/code&gt; is true, the bot should cancel open orders it's responsible for, stop placing new ones, log that it halted and why, and exit cleanly with status &lt;code&gt;0&lt;/code&gt;. That last detail is why &lt;code&gt;Restart=on-failure&lt;/code&gt; from Step 2 matters: a clean exit is not a failure, so systemd leaves the service stopped instead of bringing it straight back up mid-halt. With &lt;code&gt;Restart=always&lt;/code&gt; the kill switch would look like it worked for about five seconds.&lt;/p&gt;

&lt;p&gt;Test this on a schedule, not just once after you write it: &lt;code&gt;sudo touch /etc/trading-bot/HALT&lt;/code&gt;, watch the bot log its halt and exit, confirm with &lt;code&gt;systemctl status&lt;/code&gt; that it's inactive and staying that way, then remove the file and start it again. A kill switch you've never triggered is a theory, not a control. Do this after every deploy that touches the shutdown path — that code is the least exercised by normal operation.&lt;/p&gt;

&lt;p&gt;Back up &lt;code&gt;/etc/trading-bot&lt;/code&gt; and the bot's own data directory the same way you'd back up anything else that would hurt to lose — &lt;a href="https://overnight.host/help/back-up-your-vps/" rel="noopener noreferrer"&gt;back up your VPS&lt;/a&gt; covers what that involves on our machines. A kill switch and a key file are both small, and both are exactly what people forget when "backup" means only the code in git.&lt;/p&gt;

&lt;h2&gt;
  
  
  On overnight.host
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is what we sell. A 1 GiB Starter runs a single Python bot process comfortably around the clock; move up a tier once you're running several bots at once, logging heavily, or keeping a local database of fills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux KVM VPS&lt;/strong&gt; — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.&lt;/p&gt;

&lt;p&gt;You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.overnight.host/order/vps-starter" rel="noopener noreferrer"&gt;Order vps-starter →&lt;/a&gt; · &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;Linux KVM VPS overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How much RAM does a trading bot actually need?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Comfortably under 300 MB for a single bot holding one exchange connection and a modest amount of in-memory state. A 1 GiB VPS covers that with room for the OS and chrony; multiple bots or a growing local database is what pushes you to size up, not the baseline footprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does NAT IPv4 matter for a trading bot?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, as long as the bot only makes outbound calls to the exchange's REST and websocket endpoints, which is how virtually every exchange API works. NAT and forwarded ports only matter for services that accept inbound connections, and a trading bot doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use chrony instead of the default time sync?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemd-timesyncd&lt;/code&gt; is fine for general use, but chrony tracks and corrects drift more tightly and gives you &lt;code&gt;chronyc tracking&lt;/code&gt; to verify the offset. Exchanges that sign requests with a timestamp and a tolerance window reject calls from a clock that's drifted, and that failure looks like a bot bug until you check the clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my kill switch stop working after I changed the restart policy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Almost always because the unit is set to &lt;code&gt;Restart=always&lt;/code&gt; instead of &lt;code&gt;Restart=on-failure&lt;/code&gt;. &lt;code&gt;Restart=always&lt;/code&gt; restarts on any exit, including a clean, deliberate one, so a kill switch that exits with status 0 gets undone within seconds. &lt;code&gt;Restart=on-failure&lt;/code&gt; only restarts on a non-zero exit: crashes recover on their own, deliberate halts stay halted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this guide going to tell me what strategy to run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. This is entirely about keeping a bot you've already built running reliably and safely on a server — restart behaviour, credentials, clock sync, logging, and a kill switch. What the bot decides to trade, and whether it should, is a decision for you to make with your own risk tolerance, not something to take from a hosting guide.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the person who runs &lt;a href="https://overnight.host" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt;: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at &lt;a href="https://up.overnight.host" rel="noopener noreferrer"&gt;up.overnight.host&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>trading</category>
      <category>python</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Scheduling jobs on a VPS with cron — a practical guide</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:16:17 +0000</pubDate>
      <link>https://dev.to/overnighthost/scheduling-jobs-on-a-vps-with-cron-a-practical-guide-5322</link>
      <guid>https://dev.to/overnighthost/scheduling-jobs-on-a-vps-with-cron-a-practical-guide-5322</guid>
      <description>&lt;p&gt;One of the first things you want from your own server is for it to &lt;em&gt;do things while you sleep&lt;/em&gt;: run a backup at 3 a.m., restart a flaky service nightly, pull fresh data every fifteen minutes. On Linux, the oldest and most reliable tool for that is &lt;code&gt;cron&lt;/code&gt;. It is on practically every VPS already, it needs no daemon you have to install, and once you understand the five-field syntax it stops being intimidating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five fields
&lt;/h2&gt;

&lt;p&gt;A cron schedule is five space-separated fields, followed by the command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;┌─ &lt;span class="n"&gt;minute&lt;/span&gt; (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;59&lt;/span&gt;)
│ ┌─ &lt;span class="n"&gt;hour&lt;/span&gt; (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;23&lt;/span&gt;)
│ │ ┌─ &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt; (&lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;31&lt;/span&gt;)
│ │ │ ┌─ &lt;span class="n"&gt;month&lt;/span&gt; (&lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;12&lt;/span&gt;)
│ │ │ │ ┌─ &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;week&lt;/span&gt; (&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;7&lt;/span&gt;, &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;both&lt;/span&gt; &lt;span class="n"&gt;Sunday&lt;/span&gt;)
│ │ │ │ │
* * * * *  &lt;span class="n"&gt;command&lt;/span&gt;-&lt;span class="n"&gt;to&lt;/span&gt;-&lt;span class="n"&gt;run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;*&lt;/code&gt; means "every value." So &lt;code&gt;*/15 * * * *&lt;/code&gt; is "every 15 minutes," &lt;code&gt;0 3 * * *&lt;/code&gt; is "03:00 every day," and &lt;code&gt;0 9 * * 1&lt;/code&gt; is "09:00 every Monday." That is 90% of what you will ever write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing your crontab
&lt;/h2&gt;

&lt;p&gt;Each user has their own crontab. Edit yours with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;crontab &lt;span class="nt"&gt;-e&lt;/span&gt;      &lt;span class="c"&gt;# opens your personal crontab in $EDITOR&lt;/span&gt;
crontab &lt;span class="nt"&gt;-l&lt;/span&gt;      &lt;span class="c"&gt;# list current jobs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a line and save. For example, a quarter-hourly sync and a nightly backup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * /usr/bin/curl -fsS https://api.example.com/sync &amp;gt;&amp;gt; /var/log/sync.log 2&amp;gt;&amp;amp;1
0 3 * * *    /home/deploy/backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run something as root (system maintenance, service restarts), use &lt;code&gt;sudo crontab -e&lt;/code&gt;, or drop a file into &lt;code&gt;/etc/cron.d/&lt;/code&gt; with an extra username field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that trip everyone up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Cron has almost no &lt;code&gt;PATH&lt;/code&gt;.&lt;/strong&gt; Inside cron, &lt;code&gt;$PATH&lt;/code&gt; is minimal, so a bare &lt;code&gt;python&lt;/code&gt; or &lt;code&gt;node&lt;/code&gt; often "works on the command line but not in cron." Always use absolute paths (&lt;code&gt;/usr/bin/python3&lt;/code&gt;), or set &lt;code&gt;PATH=&lt;/code&gt; at the top of the crontab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Output goes to mail, or nowhere.&lt;/strong&gt; If a job prints anything and you do not redirect it, cron tries to email it locally and you never see it. Redirect both streams to a log so failures are visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 3 * * * /home/deploy/backup.sh &amp;gt;&amp;gt; /var/log/backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; is the important half — it captures errors, not just normal output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Timezone.&lt;/strong&gt; Cron uses the server's system clock. On a multi-region setup your VPS might be on UTC, not your local time. Check with &lt;code&gt;timedatectl&lt;/code&gt;, and either do the mental math or set &lt;code&gt;CRON_TZ=Europe/Berlin&lt;/code&gt; (or your zone) at the top of the crontab so &lt;code&gt;0 3 * * *&lt;/code&gt; means what you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't let jobs pile up
&lt;/h2&gt;

&lt;p&gt;If a job can run longer than its interval (a backup that occasionally takes 20 minutes on a 15-minute schedule), guard it with &lt;code&gt;flock&lt;/code&gt; so a slow run never overlaps the next one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * /usr/bin/flock -n /tmp/sync.lock /home/deploy/sync.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-n&lt;/code&gt; means "if the lock is held, skip this run rather than queue." Overlapping cron jobs are a classic cause of a server quietly grinding itself to a halt.&lt;/p&gt;

&lt;h2&gt;
  
  
  When cron is the wrong tool
&lt;/h2&gt;

&lt;p&gt;For sub-minute scheduling, jobs with dependencies, or anything that needs retries and a dashboard, reach for &lt;code&gt;systemd&lt;/code&gt; timers or a proper job runner instead. Cron is perfect for simple, independent, time-based tasks — and most of what a small server needs is exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick way to get the syntax right
&lt;/h2&gt;

&lt;p&gt;If you would rather not count asterisks in your head, we keep a free &lt;a href="https://overnight.host/tools/cron-expression-generator/" rel="noopener noreferrer"&gt;cron expression generator&lt;/a&gt; that turns plain-English schedules into the exact five-field line (and explains an existing one back to you). It is one of a handful of small, free tools we host on overnight.host — no signup, just useful. Whether your box lives in an EU or US region, the cron syntax is identical, so the schedule you build there drops straight into &lt;code&gt;crontab -e&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>A practical UFW firewall for your VPS — open only what you need</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:15:41 +0000</pubDate>
      <link>https://dev.to/overnighthost/a-practical-ufw-firewall-for-your-vps-open-only-what-you-need-3lln</link>
      <guid>https://dev.to/overnighthost/a-practical-ufw-firewall-for-your-vps-open-only-what-you-need-3lln</guid>
      <description>&lt;p&gt;A fresh VPS arrives with every port reachable from the entire internet. Most of them have nothing listening, but the moment you start a database, a cache, or a dev server bound to &lt;code&gt;0.0.0.0&lt;/code&gt;, it is exposed — and bots scan for exactly that within minutes. A firewall flips the default from "open unless I close it" to "closed unless I open it." On Ubuntu and Debian, &lt;code&gt;ufw&lt;/code&gt; (Uncomplicated Firewall) makes that a five-minute job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden rule: allow SSH before you enable
&lt;/h2&gt;

&lt;p&gt;The single most common way people lock themselves out of a VPS is enabling a default-deny firewall without first allowing their own SSH port. Do it in this order, every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ufw          &lt;span class="c"&gt;# usually already present&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH        &lt;span class="c"&gt;# or: sudo ufw allow 22/tcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default deny incoming&lt;/code&gt; blocks everything arriving from outside. &lt;code&gt;default allow outgoing&lt;/code&gt; lets your server reach the internet (package updates, API calls) normally. The SSH rule is what keeps your session alive after you turn the firewall on.&lt;/p&gt;

&lt;p&gt;Only now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you moved SSH to a custom port, allow that port instead of &lt;code&gt;OpenSSH&lt;/code&gt;, and confirm it works in a second terminal before closing the one you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open the services you actually run
&lt;/h2&gt;

&lt;p&gt;Add a rule per public service. A web server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="c"&gt;# or the named profile if installed:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="s1"&gt;'Nginx Full'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A game server usually needs a specific UDP and/or TCP port — for example a Minecraft Java server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 25565/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle is to map each rule to a service you are deliberately exposing. If you cannot name the service behind a port, you probably should not open it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep databases private
&lt;/h2&gt;

&lt;p&gt;The biggest win is &lt;em&gt;not&lt;/em&gt; opening your datastore. Postgres (5432), MySQL (3306), Redis (6379) and friends should listen on &lt;code&gt;127.0.0.1&lt;/code&gt; or a private network interface, never the public one. If your app runs on the same box as its database, the database needs no firewall rule at all — local traffic never traverses the public interface.&lt;/p&gt;

&lt;p&gt;If you must reach a database from another server, scope the rule to that source address rather than the whole world:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow from 203.0.113.10 to any port 5432 proto tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line is the difference between "my teammate's server can connect" and "the entire internet can try."&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate-limit SSH against brute force
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ufw&lt;/code&gt; has a built-in throttle that temporarily blocks an address making too many connections in a short window — handy for the constant SSH login attempts every public box receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw limit OpenSSH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not a replacement for key-based auth and tools like fail2ban, but it is a free first layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect, edit, and undo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status numbered     &lt;span class="c"&gt;# list rules with index numbers&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw delete 3            &lt;span class="c"&gt;# remove rule #3&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload              &lt;span class="c"&gt;# reapply after edits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because rules are numbered, you can remove a mistake without flushing everything. Keep the list short — a firewall you understand at a glance is one you will actually maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sensible default set
&lt;/h2&gt;

&lt;p&gt;For a typical web VPS, the whole policy is four ideas: deny incoming by default, allow and rate-limit SSH, allow 80/443, and keep every datastore bound to localhost. That is enough to take a box from "exposed by default" to "exposes only what you chose," which is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing note
&lt;/h2&gt;

&lt;p&gt;A firewall is layer one, not the whole story — combine it with SSH keys, prompt updates, and least-privilege services. If you would rather start from a platform that already takes isolation seriously, &lt;a href="https://overnight.host/" rel="noopener noreferrer"&gt;overnight.host&lt;/a&gt; runs full-root KVM VPS in multiple regions (EU and US), where each tenant is genuinely isolated and the specs are honest — you still own your firewall, we just give you a clean, well-fenced box to run it on.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Choosing a VPS region: latency, data residency, and where your users actually are</title>
      <dc:creator>overnight.host</dc:creator>
      <pubDate>Tue, 23 Jun 2026 07:18:49 +0000</pubDate>
      <link>https://dev.to/overnighthost/choosing-a-vps-region-latency-data-residency-and-where-your-users-actually-are-27cn</link>
      <guid>https://dev.to/overnighthost/choosing-a-vps-region-latency-data-residency-and-where-your-users-actually-are-27cn</guid>
      <description>&lt;p&gt;Picking a data-centre region feels like a minor checkbox at checkout, but it quietly shapes how fast your service feels and which rules apply to your data. Here is how to choose it deliberately instead of accepting whatever the default happens to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Put the server near your users, not near you
&lt;/h2&gt;

&lt;p&gt;Network latency is dominated by physical distance. Light in fibre is fast but not instant, and every router adds a little more. As a rough mental model, a round trip across an ocean is ~80-150 ms, within a continent ~10-40 ms, and within a metro ~1-5 ms. If your developer machine is in Berlin but your players are in Texas, hosting in Germany "because that is where you are" can add 100+ ms to every interaction for them.&lt;/p&gt;

&lt;p&gt;Test it before you commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# round-trip time to a host in a candidate region&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 5 some-host-in-that-region.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Match the latency budget to the use case
&lt;/h2&gt;

&lt;p&gt;Not everything needs to be twitch-fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Competitive game servers, voice, real-time apps:&lt;/strong&gt; latency is everything. Pick the region closest to the bulk of your players, even if it splits your audience across two servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web apps and dashboards:&lt;/strong&gt; users tolerate more, and a CDN in front hides a lot. Origin region still matters for dynamic, database-backed requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch jobs, scrapers, build runners, bots:&lt;/strong&gt; region barely matters for throughput — put them wherever is cheapest or most convenient, with one exception below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Data residency and compliance
&lt;/h2&gt;

&lt;p&gt;Where your data physically sits can carry legal weight. If you handle personal data of EU residents, keeping that data in an EU region is the simplest way to stay on the right side of GDPR expectations and to answer the "where is our data?" question honestly. US and other jurisdictions have their own rules. The point is to choose region as a deliberate compliance decision, not an afterthought — and to be honest with your own users about where their data lives rather than overclaiming.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A CDN changes the math for static content
&lt;/h2&gt;

&lt;p&gt;If most of what you serve is cacheable — images, CSS, JS, static pages — a CDN like Cloudflare serves it from an edge near each visitor regardless of where your origin lives. In that case the origin region mostly affects cache misses and dynamic requests. If your traffic is heavily dynamic or database-driven, the origin region matters a lot more, because every request goes the full distance.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. When to go multi-region
&lt;/h2&gt;

&lt;p&gt;Reach for multiple regions when you genuinely have two centres of gravity (say, a European and a North American audience) and latency to one of them is hurting. Multi-region adds real operational complexity — data sync, failover, more moving parts — so do it because the numbers demand it, not because it sounds impressive. Many projects are better served by one well-chosen region plus a CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Ping a few candidates, put the origin near the users who feel latency most, keep regulated data in an appropriate jurisdiction, and let a CDN cover the static stuff. "Closest to me" is rarely the right default.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;overnight.host runs VPS and game servers in multiple regions — EU and US today, with more coming — so you can pick the one nearest your users rather than nearest us. Honest specs, full root, &lt;a href="https://overnight.host/vps/" rel="noopener noreferrer"&gt;cancel-anytime billing&lt;/a&gt;, in your region.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vps</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
