<?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: Sergej Voronko</title>
    <description>The latest articles on DEV Community by Sergej Voronko (@linuxcore).</description>
    <link>https://dev.to/linuxcore</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%2F4195468%2Fcaa1ff11-44a2-4c36-bc32-9207c04edc14.png</url>
      <title>DEV Community: Sergej Voronko</title>
      <link>https://dev.to/linuxcore</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/linuxcore"/>
    <language>en</language>
    <item>
      <title>Tailscale vs Headscale: Self-Hosting Control</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Wed, 11 Nov 2026 23:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/tailscale-vs-headscale-self-hosting-control-kg8</link>
      <guid>https://dev.to/linuxcore/tailscale-vs-headscale-self-hosting-control-kg8</guid>
      <description>&lt;p&gt;Self-hosting Headscale is for homelab admins who require total sovereignty over their network control plane. For most users, Tailscale's official free tier offers enough capacity and removes the maintenance burden. This comparison breaks down the technical differences, deployment realities, and client configurations needed to run a fully self-hosted mesh network.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Control Plane vs the Data Plane
&lt;/h2&gt;

&lt;p&gt;Mesh VPNs replaced traditional hub-and-spoke networks by allowing devices to communicate directly. WireGuard made this practical by providing lightweight, high-performance encrypted tunnels. However, managing WireGuard keys across homelab servers, laptops, and mobile devices quickly becomes a configuration nightmare.&lt;/p&gt;

&lt;p&gt;The proprietary Tailscale coordination server securely distributes public keys to all authorized nodes. Headscale implements the exact same API endpoints. When a client authenticates, Headscale transmits a network map to the client, including the public keys and IP addresses of other nodes. The client configures its local WireGuard interface using this data.&lt;/p&gt;

&lt;p&gt;Headscale replaces only the coordination server. When you run Headscale, you still install the official Tailscale client software on your devices. You point those clients to your own server instead of the Tailscale cloud. The data plane remains exactly the same. Peer-to-peer tunnels never pass through your Headscale server unless they fall back to a relay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Headscale in Your Homelab
&lt;/h2&gt;

&lt;p&gt;Headscale is distributed as a single Go binary or a Docker container. It requires a dedicated domain name and valid TLS certificates. Tailscale clients reject self-signed certificates without explicit overrides, making a valid Let's Encrypt certificate mandatory.&lt;/p&gt;

&lt;p&gt;Here is a minimal Docker Compose stack for Headscale using a host-mounted directory for configuration and data:&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="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;headscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale/headscale:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"127.0.0.1:8080:8080"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/etc/headscale&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/var/lib/headscale&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale serve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You must place a valid &lt;code&gt;config.yaml&lt;/code&gt; in the configuration directory before starting the container. The default configuration uses SQLite for storage, which handles all Headscale use cases. PostgreSQL is supported for legacy reasons, but it is in maintenance mode and discouraged.&lt;/p&gt;

&lt;p&gt;A reverse proxy like Caddy, Traefik, or Nginx should sit in front of Headscale to handle HTTPS termination on port 443 and proxy the traffic to port 8080. You must ensure the host server has a static IP address or reliable dynamic DNS. Because Headscale orchestrates the entire mesh network, its endpoint requires high availability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client Configuration and Device Registration
&lt;/h2&gt;

&lt;p&gt;Pointing official clients to your custom server requires overriding the default login URL. On Linux, run the client with the login server flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale up --login-server https://headscale.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile platforms require different steps. On iOS, open the Tailscale app, tap the options menu on the login screen, and select the custom coordination server option. Enter your Headscale URL. Instructions for connecting Apple devices are available at the &lt;code&gt;/apple&lt;/code&gt; endpoint, with downloadable configuration profiles provided for macOS under &lt;code&gt;/apple/{platform}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Headscale provides no built-in web GUI for administration. You manage users and devices through the command line. To register a node, the client prints an authentication command. Run that command inside the Headscale 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 &lt;span class="nb"&gt;exec&lt;/span&gt; headscale headscale nodes register --user homelab --key nodekey:xxxxxxxxxxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, generate a pre-authentication key and pass it to the client during setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; headscale headscale preauthkeys create --user homelab --expiration 24h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then supply this key to your client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale up --login-server https://headscale.example.com --authkey &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;your_key&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  DERP Relaying and NAT Traversal
&lt;/h2&gt;

&lt;p&gt;WireGuard connections often fail through restrictive firewalls or Carrier-Grade NAT. Tailscale implements Designated Encrypted Relay for Packets servers, commonly called DERP, to relay traffic when a direct peer-to-peer connection fails.&lt;/p&gt;

&lt;p&gt;Tailscale provides a global network of DERP relays for free. Self-hosting Headscale means you lose access to this global network. Headscale includes an embedded DERP server, but it only exists in the single geographic location where you deployed the container. If you have nodes in Europe and Asia falling back to a relay in North America, latency will spike.&lt;/p&gt;

&lt;p&gt;To support the embedded DERP server, you must expose UDP port 3478 and TCP port 443. For a true multi-region setup, you have to deploy and configure custom DERP nodes manually on separate virtual private servers and link them via the Headscale configuration file. This adds significant cost and maintenance overhead compared to the managed Tailscale service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access Control Lists and Identity
&lt;/h2&gt;

&lt;p&gt;Tailscale uses a custom HuJSON format for Access Control Lists. Headscale parses this exact same format. Instead of editing policies in a web browser, you define your ACLs in a local file on the Headscale server and reference it in your main configuration. This approach works well for infrastructure-as-code workflows, allowing you to track network policies in a Git repository and deploy them alongside your server configuration.&lt;/p&gt;

&lt;p&gt;Headscale also supports OpenID Connect. This allows you to integrate self-hosted identity providers like Authelia, Authentik, or Keycloak. Users authenticate against your own identity stack before joining the mesh network. This integration replaces the standard Google, Microsoft, or GitHub single sign-on options provided by the official Tailscale service. By pairing Headscale with a self-hosted OIDC provider, your entire authentication chain remains fully offline and under your direct control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance vs Official Free Tier
&lt;/h2&gt;

&lt;p&gt;Running Headscale means taking on the operational burden of a critical network service. If your Headscale server goes down, existing peer-to-peer tunnels remain active until keys expire, but no new devices can join and IP roaming breaks.&lt;/p&gt;

&lt;p&gt;Maintaining Headscale involves tracking upstream Tailscale changes. Headscale developers frequently release updates to maintain compatibility with new Tailscale client versions. If you update your Tailscale clients on iOS or Android but leave Headscale running an outdated version, API mismatch errors occur and devices fail to authenticate.&lt;/p&gt;

&lt;p&gt;You must implement automated backups for the data directory. If you lose the SQLite database and private keys, you have to rebuild the entire network from scratch and re-register every single device.&lt;/p&gt;

&lt;p&gt;Tailscale offers a free Personal plan supporting up to 6 users and unlimited user devices. The official service provides high availability, global DERP relays, and a web-based admin console. Choose Headscale if your threat model forbids third-party metadata collection, or if you need to run an air-gapped network. Choose Tailscale for zero-maintenance operation. The decision comes down to the time you want to spend managing the network versus using the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/wireguard-tailscale-guide/" rel="noopener noreferrer"&gt;WireGuard vs Tailscale for Homelab Remote Access (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/self-hosting-behind-cgnat-homelab-guide/" rel="noopener noreferrer"&gt;Self-Hosting Behind CGNAT: Tunnels and Relays&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/docker-compose-homelab-stack/" rel="noopener noreferrer"&gt;Docker Compose Homelab Stack: 20 Services, One File&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Can Headscale use the official Tailscale client apps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Headscale uses the exact same Tailscale clients for Linux, Windows, macOS, iOS, and Android. You point them to your custom coordination server instead of the default Tailscale infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Headscale have a web interface?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Headscale operates entirely via the command line by default. Community projects like Headplane provide a web UI, but require deploying and connecting a separate Docker container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I lose access to Tailscale MagicDNS with Headscale?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Headscale fully supports MagicDNS. You can configure your own base domain in the Headscale configuration file, and devices will resolve each other by hostname.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/homelab/tailscale-vs-headscale-self-hosted-guide/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>tailscale</category>
      <category>vpn</category>
      <category>networking</category>
    </item>
    <item>
      <title>Multipass 1.17 for Homelab: Fast Debian and Alpine VMs</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Fri, 06 Nov 2026 23:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/multipass-117-for-homelab-fast-debian-and-alpine-vms-kob</link>
      <guid>https://dev.to/linuxcore/multipass-117-for-homelab-fast-debian-and-alpine-vms-kob</guid>
      <description>&lt;p&gt;Multipass 1.17 removes the Ubuntu-only restriction for local virtual machines. Canonical's lightweight CLI orchestrator now directly provisions Debian and Fedora instances. For homelabbers testing pipelines or experimenting with container stacks, Multipass replaces the heavy overhead of spinning up full Proxmox VMs or maintaining Vagrant toolchains. The software wraps native hypervisors (KVM on Linux, the Apple Virtualization Framework on macOS, and the Host Compute System API on Windows) to launch disposable Linux environments in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-Ubuntu Distros in Your Homelab
&lt;/h2&gt;

&lt;p&gt;The 1.17 release redesigns the Multipass image catalog to support third-party Linux distributions. Before this update, validating a service against a Debian environment meant provisioning a second virtualization stack or manually converting disk images. The tool now integrates a broader operating system catalog natively.&lt;/p&gt;

&lt;p&gt;Listing available images using the CLI confirms the newly supported distributions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass find
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output includes familiar Ubuntu LTS releases alongside &lt;code&gt;debian&lt;/code&gt; and &lt;code&gt;fedora&lt;/code&gt; images. Launching a Debian test node requires a single command specifying the distribution and the desired hardware resources.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass launch debian &lt;span class="nt"&gt;--name&lt;/span&gt; dev-debian &lt;span class="nt"&gt;--cpus&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory&lt;/span&gt; 4G &lt;span class="nt"&gt;--disk&lt;/span&gt; 10G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multipass provisions the virtual disk, configures the network bridge, and injects the SSH keys. Once the CLI returns control, the Debian VM is active. Connecting to the shell is immediate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass shell dev-debian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Booting Alpine Linux from a Custom URL
&lt;/h2&gt;

&lt;p&gt;While Debian and Fedora are built into the default catalog, Multipass 1.17 also supports launching instances directly from custom image URLs. The CLI accepts &lt;code&gt;.img&lt;/code&gt; or &lt;code&gt;.qcow2&lt;/code&gt; files over HTTPS, making minimal distributions like Alpine Linux highly accessible.&lt;/p&gt;

&lt;p&gt;Alpine provides official QEMU &lt;code&gt;qcow2&lt;/code&gt; cloud images equipped with cloud-init. The Multipass daemon downloads the image, configures the hypervisor backend, and boots the VM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass launch https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/cloud/nocloud_alpine-3.20.3-x86_64-bios-cloudinit-r0.qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; alpine-node &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 512M &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk&lt;/span&gt; 1G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multipass defaults to allocating 1 CPU core, 1GB of RAM, and a 5GB disk. While these settings suit Ubuntu, Alpine Linux requires far less overhead. A user can provision a functional Alpine VM with just 512MB of RAM and 1GB of disk space. Confirm the resource consumption by querying the instance status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass info alpine-node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output displays the VM state, assigned IPv4 address, system architecture, and exact memory utilization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud-Init for Cross-Distro Provisioning
&lt;/h2&gt;

&lt;p&gt;Multipass relies heavily on &lt;code&gt;cloud-init&lt;/code&gt; to customize virtual machines during the first boot. Launching non-Ubuntu images introduces a common access issue: the default user is no longer &lt;code&gt;ubuntu&lt;/code&gt;. Debian images default to the &lt;code&gt;debian&lt;/code&gt; user, and Alpine images default to &lt;code&gt;alpine&lt;/code&gt;. Furthermore, Multipass file transfer commands assume the &lt;code&gt;/home/ubuntu&lt;/code&gt; path exists.&lt;/p&gt;

&lt;p&gt;Passing a custom &lt;code&gt;cloud-config.yaml&lt;/code&gt; file normalizes access and configuration across all distributions. The following configuration creates a standard user, grants passwordless sudo access, and imports an SSH key from GitHub.&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="c1"&gt;# cloud-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sysadmin&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;ssh_import_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gh:yourgithubusername&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the configuration during VM creation using the &lt;code&gt;--cloud-init&lt;/code&gt; flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass launch debian &lt;span class="nt"&gt;--name&lt;/span&gt; provisioned-debian &lt;span class="nt"&gt;--cloud-init&lt;/span&gt; cloud-config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the VM boots, access the instance using standard SSH instead of the Multipass shell wrapper, or use &lt;code&gt;multipass exec provisioned-debian -- &amp;lt;command&amp;gt;&lt;/code&gt; to run scripts remotely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Docker Inside a Multipass VM
&lt;/h2&gt;

&lt;p&gt;Homelab environments often require disposable Docker hosts. Multipass VMs provide strong isolation for testing untrusted containers without risking the host system or the primary Proxmox hypervisor. Deploying Docker via cloud-init automates the entire installation process. Save the following configuration as &lt;code&gt;docker-init.yaml&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="c1"&gt;# docker-init.yaml&lt;/span&gt;
&lt;span class="na"&gt;package_update&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apt-transport-https&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ca-certificates&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;curl&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gnupg&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;lsb-release&lt;/span&gt;
&lt;span class="na"&gt;runcmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;curl -fsSL https://get.docker.com | sh&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl enable docker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl start docker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;usermod -aG docker debian&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Launch a dedicated Debian Docker host with two CPU cores and 4GB of RAM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass launch debian &lt;span class="nt"&gt;--name&lt;/span&gt; docker-host &lt;span class="nt"&gt;--cpus&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory&lt;/span&gt; 4G &lt;span class="nt"&gt;--cloud-init&lt;/span&gt; docker-init.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait a few moments for the package installation to finish. The VM operates as a fully functional, disposable Docker environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass &lt;span class="nb"&gt;exec &lt;/span&gt;docker-host &lt;span class="nt"&gt;--&lt;/span&gt; docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Multipass Gotchas for Non-Ubuntu Distributions
&lt;/h2&gt;

&lt;p&gt;When running operating systems other than Ubuntu, certain default CLI behaviors fail because the underlying system assumptions change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mounting Host Directories&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;multipass mount&lt;/code&gt; command links a local host directory directly into the virtual machine's filesystem. This maps a source code folder into the VM so the host editor can modify files while the guest executes them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass mount /local/path/to/project bridged-node:/mnt/project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the target path is omitted, the CLI automatically attempts to mount the directory into &lt;code&gt;/home/ubuntu/&amp;lt;folder&amp;gt;&lt;/code&gt;. This command fails silently or throws path errors on Debian or Alpine VMs because the directory structure differs. Always specify an explicit target path like &lt;code&gt;/mnt/project&lt;/code&gt; when working outside the Canonical ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File Transfers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same path limitation applies to the &lt;code&gt;multipass transfer&lt;/code&gt; command. Moving an archive into a non-Ubuntu VM requires explicitly targeting a universal directory such as &lt;code&gt;/tmp&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;multipass transfer app-config.tar.gz dev-debian:/tmp/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attempting to push files directly into the VM without a destination path defaults to &lt;code&gt;/home/ubuntu/&lt;/code&gt; and fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network Isolation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multipass networks default to NAT. Virtual machines share the host's IP address outbound but are not directly reachable from the wider local area network. To expose a Multipass VM to the rest of the homelab, use the &lt;code&gt;--network&lt;/code&gt; flag to bridge the connection.&lt;/p&gt;

&lt;p&gt;List the host's physical and virtual interfaces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass networks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the desired interface name to the launch command. The VM will request an IP address directly from the local DHCP server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass launch fedora &lt;span class="nt"&gt;--name&lt;/span&gt; bridged-node &lt;span class="nt"&gt;--network&lt;/span&gt; en0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Teardown and Resource Management
&lt;/h2&gt;

&lt;p&gt;The Multipass architecture treats virtual machines as temporary instances. When a test pipeline concludes or an Alpine container host is no longer required, destroy the VM to reclaim system resources.&lt;/p&gt;

&lt;p&gt;Stop the instance gracefully.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass stop dev-debian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the instance. This marks the VM for removal but retains the data temporarily.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass delete dev-debian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Purge all deleted instances to free the disk space completely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;multipass purge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI leaves no orphaned virtual disks or stale network bridges behind. The tool abstracts the hypervisor cleanup, ensuring the host machine remains clean regardless of how many Debian or Alpine VMs are provisioned and destroyed daily.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/microvms-proxmox-ve-homelab-isolation/" rel="noopener noreferrer"&gt;MicroVMs in Proxmox: True Homelab Isolation, Simply&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/proxmox-homelab-setup/" rel="noopener noreferrer"&gt;Proxmox VE Homelab Setup: Install and Configure (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/docker-compose-homelab-stack/" rel="noopener noreferrer"&gt;Docker Compose Homelab Stack: 20 Services, One File&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/linux-security-hardening/" rel="noopener noreferrer"&gt;Linux Homelab Security Hardening: Complete Checklist (2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Can Multipass 1.17 run Alpine Linux natively?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multipass does not include Alpine in its default image catalog, but version 1.17 allows launching virtual machines directly from an Alpine .qcow2 URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Multipass support bridging to the local network?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Use the --network flag followed by the host's physical network interface name to bridge the VM and obtain a local DHCP address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do multipass mount commands fail on Debian VMs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multipass defaults to mounting directories inside /home/ubuntu. Specify an explicit target path like /mnt when working with non-Ubuntu distributions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/homelab/canonical-multipass-non-ubuntu-distros-homelab-vms/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>multipass</category>
      <category>virtualmachines</category>
      <category>debian</category>
    </item>
    <item>
      <title>Systemd-nspawn Homelab Guide: Containers Without Docker</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Wed, 04 Nov 2026 23:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/systemd-nspawn-homelab-guide-containers-without-docker-4hkm</link>
      <guid>https://dev.to/linuxcore/systemd-nspawn-homelab-guide-containers-without-docker-4hkm</guid>
      <description>&lt;p&gt;Systemd-nspawn is a namespace containerization tool built directly into systemd, allowing you to run fully isolated operating systems on your homelab without installing Docker or KVM. Most modern Linux distributions ship with it natively. Instead of running a single process behind a heavy daemon and overlay filesystem, systemd-nspawn boots a complete initialization tree directly on your host's network and storage. This guide covers how to deploy Debian 12 and Alpine Linux services using systemd-nspawn, the configurations required, and how this built-in tool compares to standard Docker setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Systemd-nspawn vs Docker for Homelab Servers
&lt;/h2&gt;

&lt;p&gt;When evaluating systemd-nspawn vs docker, the architecture dictates how you manage your homelab services. Docker and Podman are application containers. They package a single daemon, like Nginx or PostgreSQL, into immutable layers. The Docker engine manages NAT routing, volume mounts, and logs. &lt;/p&gt;

&lt;p&gt;Systemd-nspawn provides system containers. It operates identically to a Proxmox LXC container or a lightweight KVM virtual machine. You give it a root directory containing a full Linux filesystem, and it boots the OS inside a namespace. The container has its own PID 1, system logs, and user space. &lt;/p&gt;

&lt;p&gt;This architectural difference changes administration. With Docker, you pull a pre-configured image and pass environment variables. With systemd-nspawn, you drop into a shell, use a package manager to install your software, and configure it using standard Linux configuration files. You get the isolation of a container with the predictable, persistent filesystem of a bare-metal server. &lt;/p&gt;

&lt;p&gt;Storage handling presents another critical difference. Docker depends on overlayfs to stack read-only image layers with a read-write upper directory. While efficient for deploying hundreds of ephemeral web servers, overlayfs adds complexity for stateful homelab services. Persistent data in Docker requires mapping volumes or bind-mounting host paths to avoid data loss on recreation.&lt;/p&gt;

&lt;p&gt;Systemd-nspawn sidesteps overlayfs completely. It treats a standard host directory as the literal root filesystem. If you write a file to &lt;code&gt;/etc/nginx.conf&lt;/code&gt; inside the container, it appears instantly as a standard file on your host's disk. If your host uses a Btrfs filesystem, &lt;code&gt;machinectl&lt;/code&gt; automatically utilizes Btrfs subvolumes for zero-copy cloning and instant snapshots.&lt;/p&gt;

&lt;p&gt;Resource consumption also varies. Docker requires running the &lt;code&gt;dockerd&lt;/code&gt; and &lt;code&gt;containerd&lt;/code&gt; background daemons at all times, consuming RAM to manage the environment. Systemd-nspawn requires zero background management daemons on a modern Linux host. It is a native C binary that configures the kernel's control groups (cgroups) and namespaces, launches the guest init system, and exits the management path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a Systemd-nspawn Debian Container
&lt;/h2&gt;

&lt;p&gt;To create a Debian environment, you need the base systemd container utilities and &lt;code&gt;debootstrap&lt;/code&gt;, which constructs the basic Debian filesystem hierarchy.&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;systemd-container debootstrap bridge-utils
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, bootstrap a fresh Debian 12 (Bookworm) environment directly into the default machines directory at &lt;code&gt;/var/lib/machines&lt;/code&gt;. Including the &lt;code&gt;dbus&lt;/code&gt; package is a documented requirement. Without it, you cannot spawn a shell session later.&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;debootstrap &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;systemd,dbus bookworm /var/lib/machines/debian-web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the filesystem finishes building, you manage the container using &lt;code&gt;machinectl&lt;/code&gt;. Start the container and open a root shell to configure your service:&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;machinectl start debian-web
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl shell debian-web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the shell, you are running a fully isolated Debian system. You can run &lt;code&gt;apt install nginx&lt;/code&gt;, modify &lt;code&gt;/etc/nginx/nginx.conf&lt;/code&gt;, and use &lt;code&gt;systemctl enable nginx&lt;/code&gt; exactly as you would on a standalone VPS. To make the container automatically boot when your homelab server restarts, enable it on the host:&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;systemd-nspawn@debian-web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since systemd-nspawn containers are managed as standard systemd services on the host, you can apply resource limits using native systemd commands instead of Docker's specific runtime flags. If you want to limit the &lt;code&gt;debian-web&lt;/code&gt; container to 512MB of RAM and one CPU core, you apply the limit on the host via standard cgroups:&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 set-property systemd-nspawn@debian-web.service &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512M &lt;span class="nv"&gt;CPUQuota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Systemd writes these limits to a drop-in file under &lt;code&gt;/etc/systemd/system.control/&lt;/code&gt;, and the kernel enforces them instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpine Linux Containers with Systemd-nspawn
&lt;/h2&gt;

&lt;p&gt;Alpine Linux remains popular for homelabs due to its minimal footprint. You can pull an Alpine environment using &lt;code&gt;machinectl pull-tar&lt;/code&gt;. The official Alpine minirootfs tarballs integrate directly.&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;machinectl pull-tar https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-3.24.3-x86_64.tar.gz alpine-svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a common gotcha for Alpine containers. Alpine does not use systemd; it uses OpenRC. Furthermore, the standard minirootfs tarball lacks a functioning init system by default. If you run &lt;code&gt;machinectl start alpine-svc&lt;/code&gt; immediately, the boot process fails because it cannot locate &lt;code&gt;/sbin/init&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before booting the container, you must bypass the standard startup to manually install OpenRC. Use the &lt;code&gt;systemd-nspawn&lt;/code&gt; command interactively:&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;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; /var/lib/machines/alpine-svc /bin/sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the chroot environment, install OpenRC and register the basic boot services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apk add openrc
rc-update add sysinit
rc-update add bootmisc
rc-update add root
&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can start the Alpine container via &lt;code&gt;sudo machinectl start alpine-svc&lt;/code&gt;. Systemd-nspawn successfully executes the OpenRC initialization sequence.&lt;/p&gt;

&lt;p&gt;Another Alpine limitation is shell access. In a Debian container, you can type &lt;code&gt;machinectl shell debian-web&lt;/code&gt; to get a secure login prompt. This mechanism relies on &lt;code&gt;dbus&lt;/code&gt; and systemd's login services running inside the guest. Because Alpine runs OpenRC and lacks these systemd-specific integration services, &lt;code&gt;machinectl shell&lt;/code&gt; fails, throwing an error about a missing DBus connection.&lt;/p&gt;

&lt;p&gt;To administer an Alpine systemd-nspawn container after it boots, you have two options. You can use the raw nspawn command to execute a shell directly:&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;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; /var/lib/machines/alpine-svc /bin/sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, you can install an SSH server inside the Alpine container during your initial setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apk add openssh
rc-update add sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once SSH runs, you manage the container exactly like any other remote Linux host on your network, decoupling your administration workflow from &lt;code&gt;machinectl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network Configuration and Storage Mounts
&lt;/h2&gt;

&lt;p&gt;Network behavior in systemd-nspawn frequently confuses administrators because the defaults change based on how you start the container. &lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;systemd-nspawn -D /var/lib/machines/debian-web&lt;/code&gt; directly from the command line, the container shares the host network namespace. However, when you use &lt;code&gt;machinectl start debian-web&lt;/code&gt;, it uses the &lt;code&gt;systemd-nspawn@.service&lt;/code&gt; template, which hardcodes the &lt;code&gt;--network-veth&lt;/code&gt; flag. This creates a virtual ethernet pair, placing the container in an isolated private network.&lt;/p&gt;

&lt;p&gt;To map ports from the host to your isolated container, similar to Docker's &lt;code&gt;-p 8080:80&lt;/code&gt; flag, you create an &lt;code&gt;.nspawn&lt;/code&gt; configuration file. Create a new directory and file at &lt;code&gt;/etc/systemd/nspawn/debian-web.nspawn&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;[Exec]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Boot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Network]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;VirtualEthernet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tcp:8080:80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the container with &lt;code&gt;machinectl restart debian-web&lt;/code&gt;. The host forwards traffic on port 8080 directly to port 80 inside the container. &lt;/p&gt;

&lt;p&gt;If you prefer the container to obtain its own IP address directly from your homelab router, bind it to a host bridge interface instead. Assuming you have a bridge named &lt;code&gt;br0&lt;/code&gt; on your host:&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;[Network]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;VirtualEthernet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Bridge&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration makes the container operate identically to a KVM virtual machine on your local network, receiving its own DHCP lease without host NAT overhead.&lt;/p&gt;

&lt;p&gt;Homelab containers often need access to large storage pools, such as a media directory or a database backup folder. In Docker, you declare volume binds in a Compose file. With systemd-nspawn, you use the &lt;code&gt;[Files]&lt;/code&gt; section of the &lt;code&gt;.nspawn&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;To mount a host directory at &lt;code&gt;/mnt/homelab-media&lt;/code&gt; into the container at &lt;code&gt;/var/media&lt;/code&gt; in read-only mode, modify the &lt;code&gt;/etc/systemd/nspawn/debian-web.nspawn&lt;/code&gt; file:&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;[Exec]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Boot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Files]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;BindReadOnly&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/mnt/homelab-media:/var/media&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Network]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;VirtualEthernet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tcp:8080:80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These declarative configuration files completely replace Docker Compose for system containers. You define network behavior, port forwards, storage mounts, and privilege drops in one place, and systemd applies them securely during every boot sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/canonical-multipass-non-ubuntu-distros-homelab-vms/" rel="noopener noreferrer"&gt;Multipass 1.17 for Homelab: Fast Debian and Alpine VMs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/docker-container-not-a-sandbox-microvm-homelab-isolation/" rel="noopener noreferrer"&gt;Your Container Is Not a Sandbox: Use MicroVMs Instead&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/docker-compose-explained/" rel="noopener noreferrer"&gt;Docker Compose for Homelab: What It Is and How to Use It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;What is systemd-nspawn?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Systemd-nspawn is a native Linux utility that spawns namespace containers. It operates as a virtual machine manager but uses lightweight containerization, booting a full init system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is systemd-nspawn different from Docker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docker isolates single processes and relies heavily on image layers and a management daemon. Systemd-nspawn behaves more like a traditional Linux server, running a full OS tree and init system without requiring a daemon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does systemd-nspawn work with non-systemd Linux distributions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. While optimized for systemd guests like Debian, it can run Alpine Linux containers. You must manually install and configure an init system like OpenRC first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/homelab/systemd-nspawn-lightweight-linux-containers-homelab-guide/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemdnspawn</category>
      <category>containers</category>
      <category>docker</category>
    </item>
    <item>
      <title>Fail2ban vs CrowdSec: Homelab IPS Battle</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Fri, 30 Oct 2026 23:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/fail2ban-vs-crowdsec-homelab-ips-battle-j1f</link>
      <guid>https://dev.to/linuxcore/fail2ban-vs-crowdsec-homelab-ips-battle-j1f</guid>
      <description>&lt;p&gt;When evaluating fail2ban vs crowdsec for a homelab IPS, the choice replaces a local regex log scanner with a distributed consensus engine. While Fail2ban parses text logs and directly appends blocking rules to your firewall, CrowdSec separates the workload into a log parser, a Local API, and distinct remediation components (commonly known as CrowdSec bouncers). For a homelab administrator, the decision comes down to whether the immediate simplicity of a single Python script outweighs the performance and shared threat intelligence of a decoupled architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Fail2ban Processes Logs and Iptables Rules
&lt;/h2&gt;

&lt;p&gt;Fail2ban is the default intrusion prevention system for most self-hosted servers. The service is a Python daemon that runs regular expressions against system log files. When an IP address matches a failure condition too many times within a specific &lt;code&gt;findtime&lt;/code&gt; window, Fail2ban triggers an action. That action usually involves appending an IP block to an &lt;code&gt;iptables&lt;/code&gt; or &lt;code&gt;nftables&lt;/code&gt; chain on the local machine.&lt;/p&gt;

&lt;p&gt;The implementation requires zero external dependencies. You define a filter file containing a regex, link it to a jail in &lt;code&gt;jail.local&lt;/code&gt;, specify a ban duration, and start the service.&lt;/p&gt;

&lt;p&gt;The limitations appear under load. Regular expression evaluation is notoriously CPU-intensive. If a homelab exposes a public application that suffers an aggressive brute-force attack or a rapid vulnerability scan, the sudden influx of log entries forces the Python daemon to constantly evaluate complex regex rules against a fast-moving stream of text. Server CPU usage frequently spikes during these events.&lt;/p&gt;

&lt;p&gt;Relying on vanilla &lt;code&gt;iptables&lt;/code&gt; rules to drop connections from thousands of IP addresses forces the Linux kernel to perform linear searches. Unless Fail2ban is specifically configured to use an &lt;code&gt;ipset&lt;/code&gt; action, a large ban list severely degrades packet forwarding performance. A large list of banned IPs means every valid incoming packet must be checked against those sequential firewall rules before reaching its destination.&lt;/p&gt;

&lt;p&gt;Fail2ban also stores its active bans in a local SQLite database located at &lt;code&gt;/var/lib/fail2ban/fail2ban.sqlite3&lt;/code&gt;. If the &lt;code&gt;dbpurgeage&lt;/code&gt; parameter is not tuned correctly, this database grows massive over time and slows down service restarts.&lt;/p&gt;

&lt;h2&gt;
  
  
  CrowdSec Architecture and Bouncer Design
&lt;/h2&gt;

&lt;p&gt;CrowdSec is written in Go and fundamentally changes the intrusion prevention model. Instead of one monolithic script handling parsing, state management, and blocking, the architecture is split into discrete services.&lt;/p&gt;

&lt;p&gt;The core agent uses YAML configurations and Grok patterns to parse logs. Grok patterns execute much faster than raw regex evaluations in Python, reducing CPU overhead during high-volume attacks.&lt;/p&gt;

&lt;p&gt;When the agent detects malicious behavior based on defined scenarios, it does not touch the host firewall directly. Instead, it registers a decision with the Local API (LAPI), which stores the event in a local SQLite or PostgreSQL database.&lt;/p&gt;

&lt;p&gt;Remediation components (the CrowdSec bouncers) query this LAPI. The bouncer is responsible for enforcing the ban. If you install the host firewall bouncer, it creates an &lt;code&gt;ipset&lt;/code&gt; or &lt;code&gt;nftables&lt;/code&gt; set and adds the malicious IP address to it for fast lookups. Because the components communicate over an HTTP API, you can run the parsing agent on an internal web server and run the bouncer on your perimeter router.&lt;/p&gt;

&lt;p&gt;This architecture includes a community threat intelligence feature. The LAPI periodically sends anonymous data about banned IPs to a Central API. In return, your homelab receives a curated blocklist of IPs that have attacked other CrowdSec users. Your server drops packets from known malicious actors before they ever attempt a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying CrowdSec via Docker Compose
&lt;/h2&gt;

&lt;p&gt;Deploying Fail2ban in Docker often requires elevated privileges and messy host file mounts. CrowdSec is designed with containers in mind. The agent runs as a standard unprivileged container, and you configure your reverse proxy to share its log directory.&lt;/p&gt;

&lt;p&gt;Below is a minimal Docker Compose snippet based on the current official CrowdSec documentation. The configuration mounts the host log directory, exposes the Local API on port 8080, and installs common collections.&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;crowdsec&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;crowdsecurity/crowdsec:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crowdsec&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;COLLECTIONS=crowdsecurity/linux crowdsecurity/traefik crowdsecurity/http-cve&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GID=1000&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;crowdsec-db:/var/lib/crowdsec/data/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;crowdsec-config:/etc/crowdsec/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/log/auth.log:/var/log/auth.log:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/path/to/proxy/logs:/var/log/proxy: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:8080"&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;crowdsec-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;crowdsec-config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;COLLECTIONS&lt;/code&gt; environment variable automatically downloads parsers and scenarios from the CrowdSec Hub. A collection is a bundled set of parsing rules and detection thresholds for a specific service. The Local API binds to &lt;code&gt;127.0.0.1&lt;/code&gt; so that host-level bouncers can query it securely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting CrowdSec Bouncers to the LAPI
&lt;/h2&gt;

&lt;p&gt;Running the CrowdSec agent alone only populates a database with decisions; it does not block anything. You must install a bouncer to apply the remediations. &lt;/p&gt;

&lt;p&gt;Bouncers come in two primary categories: Infrastructure-level (Layer 3/4) and Application-level (Layer 7). &lt;br&gt;
The infrastructure bouncer (&lt;code&gt;crowdsec-firewall-bouncer&lt;/code&gt;) runs directly on the host operating system and issues kernel-level drops. &lt;br&gt;
The application bouncers integrate directly into reverse proxies like Traefik, Caddy, or Nginx. Instead of dropping the packet at the kernel, a Layer 7 bouncer can return a 403 Forbidden HTTP error or serve a CAPTCHA challenge to suspicious IPs.&lt;/p&gt;

&lt;p&gt;To connect a bouncer, you must generate an API token using the &lt;code&gt;cscli&lt;/code&gt; command line tool inside the CrowdSec 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 &lt;span class="nb"&gt;exec &lt;/span&gt;crowdsec cscli bouncers add homelab-firewall
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command outputs an alphanumeric token. You paste this token into the bouncer's configuration file and restart the bouncer service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Homelab Gotchas: WAL Mode and Docker Networking
&lt;/h2&gt;

&lt;p&gt;The distributed design introduces management complexity. Moving from Fail2ban to CrowdSec requires learning a new taxonomy of agents, scenarios, parsers, and bouncers. You will rely heavily on the &lt;code&gt;cscli&lt;/code&gt; tool to inspect decisions and manage collections.&lt;/p&gt;

&lt;p&gt;The SQLite database lock issue is a documented gotcha for high-traffic homelabs. By default, the CrowdSec container initializes a standard SQLite database. When multiple logs are parsed rapidly and the bouncer pulls updates simultaneously, the database can throw a "database is locked" error in the container logs. The fix requires manually adding &lt;code&gt;use_wal: true&lt;/code&gt; to the &lt;code&gt;db_config&lt;/code&gt; section of &lt;code&gt;/etc/crowdsec/config.yaml&lt;/code&gt; to enable Write-Ahead Logging, followed by a container restart.&lt;/p&gt;

&lt;p&gt;Docker networking creates another frequent issue with bouncers. If you run a bouncer in a separate Docker container on a custom bridge network, it communicates with the LAPI via a dynamic container IP address. If the bouncer container updates or restarts, Docker assigns it a new internal IP. The CrowdSec LAPI sees a new connection and leaves the old bouncer registered. Over time, stale bouncer registrations accumulate. Administrators must implement static container IPs or prune inactive bouncers periodically using the &lt;code&gt;cscli bouncers prune&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Conversely, Fail2ban requires almost no maintenance once the jail is dialed in. If you have a single virtual private server or a single home server running SSH, Fail2ban is highly effective. You install the package, enable the &lt;code&gt;sshd&lt;/code&gt; jail, and the configuration survives system reboots and package updates without demanding attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Stick With Fail2ban vs CrowdSec
&lt;/h2&gt;

&lt;p&gt;A homelab running a simple WireGuard endpoint and SSH does not require CrowdSec. Fail2ban provides immediate protection with minimal setup. The CPU penalty of regex parsing is irrelevant when your public attack surface is restricted to a single port and traffic volume is low.&lt;/p&gt;

&lt;p&gt;Homelabs hosting multiple public-facing web applications behind a reverse proxy gain significant advantages from CrowdSec. The Go-based log parsing handles heavy traffic without spiking processor usage. The community threat intelligence preemptively drops known malicious scanners, reducing the sheer volume of traffic hitting your applications. Most importantly, the bouncers allow you to decouple detection from enforcement, parsing proxy logs safely inside an isolated container while executing the ban via the host network stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/linux-security-hardening/" rel="noopener noreferrer"&gt;Linux Homelab Security Hardening: Complete Checklist (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/docker-compose-homelab-stack/" rel="noopener noreferrer"&gt;Docker Compose Homelab Stack: 20 Services, One File&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/self-host-static-ip-living-room-homelab/" rel="noopener noreferrer"&gt;Self-Hosting on a Static IP: The No-Nonsense Homelab Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;What is the main difference between Fail2ban and CrowdSec?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fail2ban uses local regex log parsing to update iptables directly. CrowdSec uses a decoupled architecture with Go-based grok parsing and a distributed threat intelligence network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Fail2ban and CrowdSec run together?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Running both on the same logs creates duplicate firewall rules and race conditions. Pick one intrusion prevention system per service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do CrowdSec bouncers cost money?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core CrowdSec engine and standard bouncers are free and open-source. The project limits API pulls for enterprise volume, but standard homelab usage remains free.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/homelab/fail2ban-vs-crowdsec-homelab-firewall/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>fail2ban</category>
      <category>crowdsec</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Astro MD vs MDX: Content Collection Build Performance</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Wed, 28 Oct 2026 23:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/astro-md-vs-mdx-content-collection-build-performance-1m9p</link>
      <guid>https://dev.to/linuxcore/astro-md-vs-mdx-content-collection-build-performance-1m9p</guid>
      <description>&lt;p&gt;Standard Markdown (&lt;code&gt;.md&lt;/code&gt;) and MDX (&lt;code&gt;.mdx&lt;/code&gt;) process content drastically differently within Astro content collections. The build pipeline parses standard Markdown as raw text data, requiring minimal memory and scaling linearly. The build process compiles MDX files into full JSX components, forcing the Vite bundler to process every &lt;code&gt;.mdx&lt;/code&gt; file as a discrete JavaScript module. This architectural difference means MDX processing introduces significant parser overhead and increases build times on low-resource homelab CI runners. Use standard Markdown for static text and reserve MDX strictly for files requiring embedded interactive components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Astro Markdown vs MDX: Parser Architecture
&lt;/h2&gt;

&lt;p&gt;Standard &lt;code&gt;.md&lt;/code&gt; files in Astro bypass the JavaScript module graph entirely. When the content collections API processes a &lt;code&gt;.md&lt;/code&gt; file, the parser reads the frontmatter for validation against your defined Zod schema. The standard remark and rehype plugins then transform the body text into HTML. The default Astro pipeline relies on a Rust-powered processor that significantly improves build times. The content remains isolated from the application's core JavaScript bundle.&lt;/p&gt;

&lt;p&gt;MDX skips lightweight text processing. When you install the &lt;code&gt;@astrojs/mdx&lt;/code&gt; package, the compiler evaluates every &lt;code&gt;.mdx&lt;/code&gt; file as a functional component. The parser transforms your Markdown syntax into JSX, injects imports for any Astro, Vue, or React components referenced in the file, and hands the resulting code to Vite. The pipeline then adds these modules to the overall dependency graph.&lt;/p&gt;

&lt;p&gt;This fundamental shift alters how the server builds the site. A basic Markdown file maps directly to static HTML output. An MDX file requires the build system to resolve imports, manage potential hydration directives, and pass the output through the Vite build pipeline before generating that same static HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Speeds and Parser Overhead
&lt;/h2&gt;

&lt;p&gt;If you run builds on a minimal Proxmox virtual machine or a cheap VPS, the performance delta between &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; becomes a hard limit. A content collection containing 1,000 standard Markdown files compiles in under a few seconds. Converting those same 1,000 files to MDX pushes the build time into the double digits and routinely triggers out-of-memory kills on runners with under 2GB of RAM.&lt;/p&gt;

&lt;p&gt;The performance drop stems from the compilation pipeline. When the build pipeline encounters an MDX file, it does not just render text. The &lt;code&gt;@mdx-js/mdx&lt;/code&gt; compiler parses the document into a Markdown Abstract Syntax Tree (AST), applies any configured remark plugins, and converts the output to an HTML AST. The compiler then applies rehype plugins and maps the resulting structure into raw JSX syntax. Finally, the process passes this JSX to the internal bundler to transpile into standard JavaScript. This entire chain executes for every single MDX file. Processing 2,000 blog posts requires 2,000 distinct JavaScript module compilations.&lt;/p&gt;

&lt;p&gt;When a homelab CI runner hits the default Node.js heap limit during an Astro build, the pipeline crashes. You can temporarily bypass the crash by forcing Node to use more memory, but this is a patch, not a fix.&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;# Expanding the heap size for heavy MDX builds&lt;/span&gt;
&lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--max-old-space-size=4096"&lt;/span&gt; npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proper fix is limiting MDX usage to files that actually require it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Component Architecture and Hydration
&lt;/h2&gt;

&lt;p&gt;Standard Markdown restricts you to standard HTML tags. You can apply custom styling via CSS, but you cannot embed stateful client-side code directly into the prose. &lt;/p&gt;

&lt;p&gt;Many developers assume they need MDX just to add custom CSS classes to alerts or styled callouts. You do not need MDX for basic structural changes. You can write or import custom remark and rehype plugins for standard Markdown that automatically inject HTML &lt;code&gt;div&lt;/code&gt; wrappers or CSS classes around standard blockquotes. Standard Markdown in Astro also utilizes Shiki for syntax highlighting by default, meaning you do not need MDX just to render formatted code blocks.&lt;/p&gt;

&lt;p&gt;MDX exists specifically to import UI components directly into the content body. This is necessary when an article requires a functional, interactive element, like a live search bar, a dynamic data table fetched from an API, or a React component tracking client-side state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
title: "Server Status"
pubDate: 2026-10-27
---

import PingTest from '../../components/PingTest.jsx';

The current server latency is listed below. Click the button to refresh the ping.

&amp;lt;PingTest client:load target="192.168.1.100" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Astro uses an Islands architecture, the MDX file itself still renders to static HTML. Client-side JavaScript only ships to the browser if you explicitly append a client directive like &lt;code&gt;client:load&lt;/code&gt; or &lt;code&gt;client:visible&lt;/code&gt; to the embedded component. Without the directive, the component renders strictly on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing MDX in Astro Content Collections
&lt;/h2&gt;

&lt;p&gt;If your project requires embedded islands, you must explicitly enable MDX integration and configure your content loader to recognize the extension. &lt;/p&gt;

&lt;p&gt;Install the official integration using the Astro CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx astro add mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optimize the &lt;code&gt;@astrojs/mdx&lt;/code&gt; integration in your configuration file. The &lt;code&gt;optimize&lt;/code&gt; flag instructs the internal rehype plugin to generate a smaller abstract syntax tree, which cuts down build times and reduces memory consumption for large collections. If you do not require standard Markdown configuration settings to apply to your MDX files, setting &lt;code&gt;extendMarkdownConfig&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; ignores inherited project-level Markdown options entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;mdx&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@astrojs/mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;mdx&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;optimize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;extendMarkdownConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, configure your content collection loader to target both file types. Astro uses the &lt;code&gt;glob&lt;/code&gt; loader for local file fetching. You can mix &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; files in the same directory by defining a multi-extension pattern. The &lt;code&gt;[^_]&lt;/code&gt; regex ensures Astro ignores files prefixed with an underscore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/content/config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;glob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/loaders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docsCollection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/[^_]*.{md,mdx}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/content/docs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;docsCollection&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When rendering the collection in a dynamic route, the &lt;code&gt;render()&lt;/code&gt; function parses both file types automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/pages/docs/[...slug].astro
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const docs = await getCollection('docs');
  return docs.map(entry =&amp;gt; ({
    params: { slug: entry.id },
    props: { entry },
  }));
}

const { entry } = Astro.props;
const { Content } = await render(entry);
---

&amp;lt;html&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;title&amp;gt;{entry.data.title}&amp;lt;/title&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;article&amp;gt;
      &amp;lt;Content /&amp;gt;
    &amp;lt;/article&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to Choose Raw Astro Markdown
&lt;/h2&gt;

&lt;p&gt;Stick to &lt;code&gt;.md&lt;/code&gt; files if your content consists only of headings, paragraphs, standard code blocks, and images. The native Astro content collections API handles all frontmatter validation and basic HTML rendering automatically. &lt;/p&gt;

&lt;p&gt;Standard Markdown formatting keeps your content portable. If you migrate away from Astro in the future, raw Markdown transfers cleanly to Hugo, Jekyll, or any other static site generator. MDX files littered with framework-specific component imports require manual clean-up and regex replacements to become standard text again. &lt;/p&gt;

&lt;p&gt;Integrating a headless CMS like Decap or Ghost is much simpler with standard Markdown. Remote content management systems typically export plain text or JSON. Forcing remote content into an MDX pipeline requires custom loaders and complex hydration strategies that break the native Astro content flow.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;.mdx&lt;/code&gt; exclusively when a specific document requires interactivity that standard Markdown cannot provide. You can safely mix &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; within the same Astro content collection. Keep the bulk of your site as fast-compiling Markdown, and selectively upgrade individual files to MDX when the content demands custom React, Vue, or Svelte components. This split approach maintains fast build times on limited CI runners while preserving the ability to embed complex UI elements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/astro/astro-7-4-build-timings-parallel-prerendering-guide/" rel="noopener noreferrer"&gt;Astro 7.4 Build Timings &amp;amp; Parallel Prerendering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/astro/astro-7-alpha-vite-v8-upgrade-what-to-expect/" rel="noopener noreferrer"&gt;Astro 7 and Vite 8: What Changes When You Upgrade&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/astro/nextjs-to-astro-5-migration-homelab-cost-performance/" rel="noopener noreferrer"&gt;Migrating Next.js to Astro: What Changes and Why&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Can I mix Markdown and MDX files in the same Astro content collection?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The glob loader accepts multiple extensions. Configure the pattern to '*&lt;em&gt;/[^_]&lt;/em&gt;.{md,mdx}' to process both file types in a single collection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does MDX ship JavaScript to the browser by default?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. MDX compiles to HTML at build time. Client-side JavaScript only ships if you append a client directive like client:load to the embedded framework component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why are my Astro MDX builds failing with out-of-memory errors?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vite bundles every MDX file as a discrete JavaScript module. Large MDX collections overwhelm CI runners with limited RAM. Enable the optimize flag in the MDX integration or increase runner memory.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/astro/astro-md-vs-mdx-performance-guide/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>astro</category>
      <category>mdx</category>
      <category>markdown</category>
    </item>
    <item>
      <title>Expose Share Links on Private Homelab Apps With Caddy</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:42:02 +0000</pubDate>
      <link>https://dev.to/linuxcore/expose-share-links-on-private-homelab-apps-with-caddy-4fmo</link>
      <guid>https://dev.to/linuxcore/expose-share-links-on-private-homelab-apps-with-caddy-4fmo</guid>
      <description>&lt;p&gt;Homelab administrators often lock down self-hosted applications using an identity provider like Authelia or Authentik. This creates a hard authentication barrier before a visitor reaches the application backend. Strict zero-trust access blocks brute-force attacks and vulnerability exploits, but it breaks a common workflow: sharing a public link with users who do not have an SSO account. You can solve this by configuring Caddy to bypass the authentication gateway for specific URLs, exposing only the exact paths required for the share links to function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caddy Forward Auth Mechanics
&lt;/h2&gt;

&lt;p&gt;Caddy provides the &lt;code&gt;forward_auth&lt;/code&gt; directive to handle external authentication. It proxies a clone of the incoming HTTP request to an authentication gateway. If the gateway returns a successful 2xx status code, Caddy copies the authorized user headers and passes the request to your application. If the gateway returns any other status code, Caddy forwards the gateway's response back to the client. This usually triggers an HTTP 302 redirect to the SSO login portal.&lt;/p&gt;

&lt;p&gt;To protect an entire application, a basic Caddyfile block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nextcloud.example.com {
    forward_auth authelia:9091 {
        uri /api/verify?rd=https://auth.example.com
        copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    }
    reverse_proxy nextcloud:80
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration rejects all unauthenticated traffic. No public share links will load under this block because the authentication gateway intercepts every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Homelab Reverse Proxy Public Share Links
&lt;/h2&gt;

&lt;p&gt;To allow external users to access specific files without a login, you must split the Caddy configuration. Caddy uses named matchers and the &lt;code&gt;handle&lt;/code&gt; directive to apply rules selectively based on the request path.&lt;/p&gt;

&lt;p&gt;For Nextcloud, public shares are generated under the &lt;code&gt;/s/&lt;/code&gt; path. Bypassing this single path is not enough. The user will see a broken HTML page without styling. Nextcloud requires CSS, JavaScript, and image assets located in other directories to render the share page correctly.&lt;/p&gt;

&lt;p&gt;This Caddyfile snippet bypasses authentication for the required paths and protects the rest of the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nextcloud.example.com {
    # Match the share path and necessary asset directories
    @public path /s/* /index.php/s/* /apps/* /core/* /dist/* /img/* /js/* /css/* /avatar/* /svg/*

    # Allow public access to the matched paths
    handle @public {
        reverse_proxy nextcloud:80
    }

    # Protect everything else with Authelia
    handle {
        forward_auth authelia:9091 {
            uri /api/verify?rd=https://auth.example.com
            copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
        }
        reverse_proxy nextcloud:80
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This setup routes any request matching the &lt;code&gt;@public&lt;/code&gt; rules directly to Nextcloud. All other requests fall through to the second &lt;code&gt;handle&lt;/code&gt; block and hit the &lt;code&gt;forward_auth&lt;/code&gt; directive.&lt;/p&gt;

&lt;p&gt;You apply the same logic if you use Authentik instead of Authelia. The URI endpoint and the copied headers differ, but the Caddy &lt;code&gt;handle&lt;/code&gt; structure remains identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    # Protect everything else with Authentik
    handle {
        forward_auth authentik:9000 {
            uri /outpost.goauthentik.io/auth/caddy
            copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name X-Authentik-Uid
        }
        reverse_proxy nextcloud:80
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The API Trap with Immich
&lt;/h2&gt;

&lt;p&gt;Not all applications handle public shares using simple static HTML paths. Modern platforms often operate as single-page applications that rely heavily on backend APIs.&lt;/p&gt;

&lt;p&gt;When you create a shared link in Immich, the frontend loads via the &lt;code&gt;/share/&lt;/code&gt; path. The frontend then makes asynchronous calls to the &lt;code&gt;/api/&lt;/code&gt; endpoints to fetch the actual photo data. If you bypass just the &lt;code&gt;/share/&lt;/code&gt; path, the user gets a blank interface. The initial page loads, but Authelia blocks the subsequent API requests.&lt;/p&gt;

&lt;p&gt;Whitelist the entire &lt;code&gt;/api/&lt;/code&gt; path and you expose the administrative endpoints, nullifying the security benefits of the reverse proxy. To securely expose Immich share links via Caddy, you must whitelist the exact API routes required for shared links. These specific routes change across major updates.&lt;/p&gt;

&lt;p&gt;Because maintaining a precise list of API paths requires constant intervention, administrators often deploy the community-maintained Immich Public Proxy. This runs as a separate, stateless Docker container that exclusively handles shared links. It strips metadata and blocks unauthorized paths automatically, removing the need to modify your Caddy forward auth rules for the main Immich instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caddy Protect Self Hosted Apps and Mobile Clients
&lt;/h2&gt;

&lt;p&gt;Many administrators implement Caddy protect self hosted apps setups to block automated scanners, but this approach breaks native mobile clients. Mobile applications for Nextcloud, Immich, and Jellyfin expect direct access to the application API. When Caddy intercepts a mobile app's API request and returns an Authelia HTML login redirect, the mobile app fails to parse the response. The application crashes or hangs. It will not display the SSO login portal.&lt;/p&gt;

&lt;p&gt;You have three options to handle native mobile applications:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bypass the API paths completely. This reduces the security benefit of the identity provider because scanners can hit the application's login form directly.&lt;/li&gt;
&lt;li&gt;Require a VPN like Tailscale or WireGuard on mobile devices. The application stays fully internal, and you only use the public domain for web access.&lt;/li&gt;
&lt;li&gt;Use application-specific bypass headers. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some mobile clients allow you to inject custom HTTP headers into your requests. You can configure Caddy to bypass &lt;code&gt;forward_auth&lt;/code&gt; if a specific secret header is present.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;immich.example.com {
    @mobile_bypass header Immich-Skip-Authelia "SuperSecretString123"

    handle @mobile_bypass {
        reverse_proxy immich:2283
    }

    handle {
        forward_auth authelia:9091 {
            uri /api/verify?rd=https://auth.example.com
            copy_headers Remote-User
        }
        reverse_proxy immich:2283
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration acts as a static API key. If the header is present, the request bypasses Authelia. You must use TLS (HTTPS) on your public domain to encrypt the header in transit and prevent interception. &lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Header Spoofing Vulnerabilities
&lt;/h2&gt;

&lt;p&gt;When Caddy validates a request through the authentication gateway, it copies identity headers to the backend application. The application reads these headers to log the user in automatically. &lt;/p&gt;

&lt;p&gt;You must ensure external users cannot spoof these headers to gain administrative access on the bypassed public routes. Caddy normally clears incoming headers before passing them to the application if configured properly, but explicit bypass blocks require manual intervention.&lt;/p&gt;

&lt;p&gt;In your Caddyfile, ensure the &lt;code&gt;reverse_proxy&lt;/code&gt; directive does not implicitly trust incoming identity headers from the outside. If an attacker visits a bypassed path like &lt;code&gt;/s/xyz&lt;/code&gt; and manually includes a &lt;code&gt;Remote-User: admin&lt;/code&gt; HTTP header, Caddy must strip it. The &lt;code&gt;forward_auth&lt;/code&gt; directive automatically overwrites these headers on a successful login, but for paths in the &lt;code&gt;@public&lt;/code&gt; block, no overwrite occurs.&lt;/p&gt;

&lt;p&gt;You must manually strip the headers from unauthenticated requests to prevent privilege escalation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;handle @public {
    request_header -Remote-User
    request_header -Remote-Groups
    request_header -Remote-Name
    request_header -Remote-Email
    reverse_proxy nextcloud:80
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This directive removes the headers before the request reaches Nextcloud. &lt;/p&gt;

&lt;p&gt;The backend application also needs verification. Nextcloud requires the &lt;code&gt;trusted_proxies&lt;/code&gt; array in its &lt;code&gt;config.php&lt;/code&gt; file to validate where the headers come from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'trusted_proxies'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'192.168.1.100'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set this to the IP address of your Caddy server or Docker network gateway. If you leave &lt;code&gt;trusted_proxies&lt;/code&gt; empty or misconfigured, Nextcloud may accept spoofed headers from untrusted sources, rendering your SSO setup useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/ansible-homelab/" rel="noopener noreferrer"&gt;Ansible for Homelabbers: Automate From One Playbook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/lets-encrypt-64-day-certificates-homelab-reverse-proxy-guide/" rel="noopener noreferrer"&gt;Let's Encrypt 64-Day Certificates: Homelab SSL Automation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/homelab/proxmox-homelab-setup/" rel="noopener noreferrer"&gt;Proxmox VE Homelab Setup: Install and Configure (2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Does forward_auth break native mobile apps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Mobile apps expect direct API access and fail when redirected to a login portal. You must either bypass the API paths, use custom headers, or rely on a VPN for mobile access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to bypass CSS and JS paths for share links?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually, yes. Nextcloud share pages rely on assets in the /apps/ and /core/ directories. Bypassing only the /s/ path results in a broken layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Authentik instead of Authelia?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Caddy's forward_auth directive works with any authentication gateway that supports the forward authentication specification.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/homelab/caddy-forward-auth-public-share-links-homelab/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>caddy</category>
      <category>authelia</category>
      <category>nextcloud</category>
    </item>
    <item>
      <title>Amazon ECS Gateway: Cheap Ingress for AWS Homelabs</title>
      <dc:creator>Sergej Voronko</dc:creator>
      <pubDate>Thu, 22 Oct 2026 04:46:56 +0000</pubDate>
      <link>https://dev.to/linuxcore/amazon-ecs-gateway-cheap-ingress-for-aws-homelabs-1714</link>
      <guid>https://dev.to/linuxcore/amazon-ecs-gateway-cheap-ingress-for-aws-homelabs-1714</guid>
      <description>&lt;p&gt;Amazon ECS Gateway solves the most expensive part of running hybrid homelab workloads on AWS: the idle Application Load Balancer (ALB). Instead of permanently running a dedicated ALB at $16.42 per month base fee (in us-east-1) at the time of writing just to wait for failover traffic, this managed ingress resource dynamically provisions your load balancer only when a container service connects. When your homelab is healthy and your cloud tasks scale to zero, ECS removes the ALB, completely eliminating the standby cost.&lt;/p&gt;

&lt;p&gt;Before October 2026, exposing an Amazon Elastic Container Service (ECS) cluster cheaply required painful trade-offs. You either ran AWS Fargate tasks with public IP addresses, which change on every deployment and force you to script your own Route 53 DNS updates, or you provisioned an ALB. An ALB provides static endpoints, AWS Certificate Manager (ACM) TLS certificates, and clean host-based routing, but AWS bills a base hourly rate regardless of traffic volume. For a homelab admin keeping a secondary Nextcloud or Bitwarden instance in AWS for disaster recovery, paying nearly $200 a year for an idle load balancer defeats the purpose of cheap cloud storage.&lt;/p&gt;

&lt;p&gt;Amazon ECS Gateway completely abstracts the load balancer away from individual services. It operates as an account-level infrastructure manager. You define your ingress standards just once, specifying your VPC subnets, security groups, and listener rules.&lt;/p&gt;

&lt;p&gt;When you create or update an ECS service, you opt in by adding a gateway connector parameter. ECS then automatically provisions the required resources directly in your account. If it is the first service connecting, ECS creates an ALB. It generates a unique DNS hostname, requests an ACM certificate, and configures the target groups automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing ALBs for Cheap ECS Ingress
&lt;/h2&gt;

&lt;p&gt;Running multiple lightweight containers in the cloud usually causes ALB sprawl. Standard configurations require either multiple ALBs or manual manipulation of listener rules to share one. ECS Gateway handles shared hosting natively. A single gateway supports up to 25 services sharing the same underlying ALB using host-based routing. You pay only for the actual infrastructure provisioned in your account. Whether you expose one homelab dashboard or twenty separate microservices, your base AWS bill remains tied to a single load balancer.&lt;/p&gt;

&lt;p&gt;Once you connect a 26th service, ECS automatically provisions a second ALB to handle the capacity, immediately doubling your base infrastructure cost. Keep your total active service count under the limit if budget optimization is your primary goal.&lt;/p&gt;

&lt;p&gt;Before ECS Gateway, sharing an ALB meant manually creating Route 53 DNS records for each new service and mapping them to the ALB canonical name. ECS Gateway handles the DNS provisioning automatically. Each service connecting to the gateway receives a unique DNS hostname instantly. You can point your homelab CNAME records directly at these dynamically generated endpoints. When the gateway tears down the ALB during scale-to-zero operations, your CNAME remains intact, ready to route traffic the moment the ALB spins back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale-to-Zero AWS Cloud Fallback
&lt;/h2&gt;

&lt;p&gt;The defining feature for hybrid homelabs is automated lifecycle management. The official documentation confirms that ECS removes the underlying ALB when no services remain connected to the gateway.&lt;/p&gt;

&lt;p&gt;If your local server loses power, you scale your standby ECS services from zero to one. ECS registers the connected service, provisions the ALB, requests the ACM certificate, and begins routing traffic. Your disaster recovery site comes online with secure ingress automatically configured. Once your local Proxmox host is restored and you scale the AWS services back to zero, ECS tears down the ALB. Your monthly AWS bill drops back to standard object storage and idle database rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating an Amazon ECS Gateway
&lt;/h2&gt;

&lt;p&gt;Setting up an ECS Gateway requires an IAM role granting ECS permission to provision ALBs, security groups, and certificates on your behalf. Once the IAM role is ready, you create the gateway using the AWS CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs create-gateway &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; homelab-fallback &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--infrastructure-role-arn&lt;/span&gt; arn:aws:iam::123456789012:role/ECSGatewayRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--configuration&lt;/span&gt; &lt;span class="s1"&gt;'{ "HostSharedAlb": { "networkConfiguration": { "subnets": [ "subnet-0abcd1234efgh5678" ] } } }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--configuration&lt;/code&gt; flag accepts a JSON object defining your infrastructure limits. By specifying &lt;code&gt;HostSharedAlb&lt;/code&gt;, you instruct the gateway to use standard Application Load Balancers configured for host-based routing. The &lt;code&gt;networkConfiguration&lt;/code&gt; block accepts your VPC subnets, dictating exactly where the load balancer interfaces are placed. If your ECS tasks sit in private subnets, ensure you select public subnets here so the ALB can route external internet traffic into your private tasks.&lt;/p&gt;

&lt;p&gt;Specific configuration settings are immutable. Your gateway name, traffic routing scheme, VPC, subnets, and listener port cannot be modified after creation. Modifying your subnets later requires creating an entirely new gateway and migrating your active services over.&lt;/p&gt;

&lt;h2&gt;
  
  
  ECS awsvpc Network Mode Requirements
&lt;/h2&gt;

&lt;p&gt;When defining your ECS task, you must specify the &lt;code&gt;awsvpc&lt;/code&gt; network mode. ECS Gateway relies on this specific networking mode to enforce security group rules. ECS automatically generates a service security group that permits inbound traffic exclusively from the ALB. This completely isolates your containers from direct internet access, even if placed in a public subnet.&lt;/p&gt;

&lt;p&gt;This requirement requires no changes if you use AWS Fargate, as &lt;code&gt;awsvpc&lt;/code&gt; is the default and only supported mode. If you run your containers on self-managed EC2 instances to save money, you must ensure your task definitions explicitly declare the &lt;code&gt;awsvpc&lt;/code&gt; mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS WAF and Security Integration
&lt;/h2&gt;

&lt;p&gt;Publicly exposing internal services carries risk, especially during an automated failover where you might not be actively monitoring logs. Previously, attaching an AWS WAF required manually linking it to an existing ALB. ECS Gateway accepts a WAF Web ACL directly in the core gateway configuration. &lt;/p&gt;

&lt;p&gt;When ECS provisions the underlying ALB, it automatically attaches the firewall rules. You can define IP allowlists limiting access strictly to your home static IP or your WireGuard exit node. If your homelab fails over to AWS, the WAF rules immediately protect the exposed containers from automated scraping and exploitation attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  ECS Gateway IAM Role Requirements
&lt;/h2&gt;

&lt;p&gt;Because ECS Gateway automates infrastructure creation, it depends on an infrastructure IAM role. ECS assumes this role to generate the ACM certificates, deploy the ALB, and configure the necessary security groups. You must create a dedicated role with trust policies allowing the &lt;code&gt;ecs.amazonaws.com&lt;/code&gt; service principal to assume it. &lt;/p&gt;

&lt;p&gt;Never reuse your standard ECS execution task roles for this purpose. The infrastructure role requires extensive permissions for Elastic Load Balancing, ACM, and Amazon EC2 security group management. Keeping this role strictly scoped prevents an attacker inside a compromised container from provisioning arbitrary AWS networking infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring ECS Gateway Costs
&lt;/h2&gt;

&lt;p&gt;While the load balancer provisioning happens automatically in the background, the resulting ALB exists directly in your AWS account. You retain full visibility into the network traffic. Standard ALB metrics automatically populate in Amazon CloudWatch. You can inspect target health, request latency, and HTTP 5xx error rates exactly as you would with a manually provisioned ALB. &lt;/p&gt;

&lt;p&gt;If a container crashes, standard Elastic Load Balancing target health checks mark the task unhealthy and stop routing traffic. Because the resources live visibly in your account, they also show up in AWS Cost Explorer, allowing you to track the exact hourly spend of your ALB while your disaster recovery site remains active.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/aws/when-to-move-homelab-to-aws/" rel="noopener noreferrer"&gt;When to Move Your Homelab Workload to AWS (And When Not To)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/aws/aws-cost-guide-spot-reserved-savings/" rel="noopener noreferrer"&gt;AWS Spot, Reserved, and Savings Plans: Cost Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/aws/ten-euro-month-aws-stack/" rel="noopener noreferrer"&gt;A Minimal AWS Stack: VPS, DNS, Monitoring, Backups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxcore.dev/aws/aws-lambda-web-functions-homelab-api-guide/" rel="noopener noreferrer"&gt;Host Homelab APIs on AWS Lambda Web Functions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;What is Amazon ECS Gateway?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is an account-level resource that automates ingress for ECS services by dynamically provisioning Application Load Balancers, TLS certificates, and DNS records on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Amazon ECS Gateway cost extra?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The gateway itself is free. You only pay for the underlying infrastructure (like ALBs) while it is actively provisioned for connected services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use ECS Gateway for Fargate tasks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. ECS Gateway supports the awsvpc network mode, which is required for AWS Fargate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://linuxcore.dev/aws/amazon-ecs-gateway-ingress-homelab-cost-guide/" rel="noopener noreferrer"&gt;linuxcore.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>aws</category>
      <category>ecs</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
