<?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>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>
