Fe
Networking Azure

Ferron on Ubuntu 24.04 on Azure User Guide

| Product: Ferron on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and configuration of Ferron on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Ferron is a fast, memory safe web server written in Rust — a lean, modern alternative to nginx and Caddy.

Ferron serves static websites, works as a reverse proxy and load balancer in front of your own application servers, terminates TLS with automatic or manual certificates, and speaks HTTP/1.1 and HTTP/2 (with experimental HTTP/3). Everything is configured from a single, readable KDL file: you declare virtual hosts, per path locations, proxying, header rewriting, access control and rate limiting in one place.

The image ships the official upstream Ferron 2.8.1 release binary (the current stable release, not a beta), fetched from the project's own GitHub release and pinned by SHA-256. The exact artifact, its digest and its provenance are recorded on the image at /opt/ferron/VERSION. Ferron runs under systemd as ferron.service as an unprivileged ferron account, binding the privileged ports 80 and 443 through a granted Linux capability rather than by running as root.

A web server that is actually serving the moment it boots. A blank web server that returns a 403 on a fresh VM reads as broken. So the image ships a coherent default site: on first boot Ferron serves a cloudimg landing page over both HTTP and HTTPS, and — to show the reverse proxy working end to end with nothing else to deploy — it also proxies the /backend/ path to a tiny local backend on the same VM. That backend is a second Ferron instance bound to 127.0.0.1:8081, reachable only over loopback, so a response arriving from /backend/ on the public port can only have been proxied. You then repoint Ferron at your own site and your own backends.

A per instance TLS certificate, and no key in the image. The HTTPS listener needs a certificate. Rather than baking one in, which would put the same private key on every customer VM, the image ships no certificate and no key at all. The certificate and its private key are generated on your VM at first boot, and ferron.service is gated so it physically cannot start before that bootstrap has completed.

No default login of any kind. Ferron the web server has no credential to leak: it is a stateless file server and request router with no console, no account and no password. The static site and the reverse proxy on ports 80 and 443 are public by design — this is a web server. You add access control, authentication and rate limiting from the configuration file when you need them.

What is included:

  • The official Ferron 2.8.1 release binary, pinned by SHA-256 and redistributed unmodified, run under systemd as ferron.service

  • A working configuration out of the box: a static site on /, and a reverse proxy on /backend/ pointing at a bundled loopback demo backend

  • HTTP on 80 and HTTPS on 443 (HTTP/2 negotiated via ALPN), with a per instance self signed certificate generated at first boot

  • Ferron's bundled tools on the image: ferron-passwd (password hashes for HTTP basic auth) and ferron-precompress (precompress static assets)

  • An nftables rule keeping the demo backend port on loopback only, matched on destination address

  • ferron-selftest, a shipped command that proves in one line that a real request was served and a real request was proxied end to end

  • The MIT licence text retained on the image at /opt/ferron/LICENSE

  • Ubuntu 24.04 LTS base with latest security patches applied at build time

  • Azure Linux Agent for seamless cloud integration and SSH key injection

  • 24/7 cloudimg support with guaranteed 24 hour response SLA

cloudimg is not affiliated with or endorsed by the Ferron project.

Prerequisites

  • Active Azure subscription, SSH public key, VNet + subnet in target region

  • Subscription to the Ferron listing on Azure Marketplace

  • The website content or backend servers you intend to serve or proxy (you can deploy first and add them later — the bundled default site and demo backend mean the appliance works standalone)

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is a sensible starting point; Ferron is lightweight and idles at a few megabytes of memory. Web serving is bound by connection and request rate rather than by storage, so for busy sites move to a compute optimised size such as Standard_F4s_v2.

Step 1: Deploy from the Azure Portal

Search Ferron in Marketplace, select the cloudimg publisher, click Create. NSG rules: TCP 80 (the HTTP listener), TCP 443 (the HTTPS listener) and TCP 22 (admin SSH), each from the networks you serve or connect from. Do not open 8081 — that is the bundled demo backend, and the image deliberately keeps it private.

Step 2: Deploy from the Azure CLI

az vm create \
  --resource-group my-ferron-rg \
  --name my-ferron-vm \
  --image cloudimg:ferron:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

# Then open the two web listeners to the networks that need them
az vm open-port --resource-group my-ferron-rg --name my-ferron-vm --port 80 --priority 1010
az vm open-port --resource-group my-ferron-rg --name my-ferron-vm --port 443 --priority 1020

Step 3: Connect and confirm Ferron is running

Log in over SSH as azureuser. First boot generates this VM's TLS certificate and starts the web server, which takes a few seconds after the VM reports ready.

sudo systemctl is-active ferron.service
sudo systemctl is-active ferron-backend.service
/opt/ferron/bin/ferron -V | head -1

Expected output:

active
active
Ferron 2.8.1

The ferron-backend.service above is not a second web server for your traffic. It is the bundled demo backend — a second Ferron instance bound to 127.0.0.1:8081 — that gives the reverse proxy something real to route to before you have added your own backends.

Confirm which process owns which port. Ferron holds the two public listeners; the demo backend is bound to the loopback address only:

sudo ss -lntp | grep ferron

Expected output (Ferron owns :80 and :443; the backend is on 127.0.0.1:8081):

LISTEN 0 4096 127.0.0.1:8081 0.0.0.0:* users:(("ferron",pid=2686,fd=19))
LISTEN 0 4096         *:80        *:* users:(("ferron",pid=2705,fd=22))
LISTEN 0 4096        *:443        *:* users:(("ferron",pid=2705,fd=26))

The Ferron web server and its bundled backend both reporting active, the pinned upstream version 2.8.1, the provenance file recording exactly which release artifact and SHA-256 the image shipped, and the listener table showing Ferron owning the public ports 80 and 443 while the demo backend is bound only to 127.0.0.1:8081

The image records exactly which upstream artifact it shipped:

cat /opt/ferron/VERSION

Step 4: Prove Ferron serves and proxies a real request

This is the check worth running first, because it exercises the whole path rather than just a listening socket.

curl -si http://127.0.0.1/ | head -9

Expected output:

HTTP/1.1 200 OK
accept-ranges: bytes
etag: W/"0a6ac01765d79074"
vary: Accept-Encoding, If-Match, If-None-Match, Range
content-type: text/html
content-length: 2284
server: Ferron
x-content-type-options: nosniff
x-served-by: ferron

Two headers together identify Ferron as the server: server: Ferron, which Ferron sets itself, and x-served-by: ferron, added by a header directive in the configuration. Now ask for the proxied path:

curl -si http://127.0.0.1/backend/ | head -9

Expected output — note the body is the backend's page, delivered through Ferron:

HTTP/1.1 200 OK
accept-ranges: bytes
etag: W/"ba3965404cc147a8"
vary: Accept-Encoding, If-Match, If-None-Match, Range
content-type: text/html
content-length: 808
server: Ferron
date: Sat, 08 Aug 2026 05:45:38 GMT
x-served-by: ferron
curl -s http://127.0.0.1/backend/ | grep -o 'CLOUDIMG_FERRON_BACKEND'

Expected output — the marker is served only by the loopback backend on 127.0.0.1:8081, so seeing it here proves Ferron proxied the request to it:

CLOUDIMG_FERRON_BACKEND

The same site works over TLS, where ALPN negotiates HTTP/2:

curl -sik https://127.0.0.1/ | head -6

Expected output:

HTTP/2 200 
accept-ranges: bytes
etag: W/"0a6ac01765d79074"
vary: Accept-Encoding, If-Match, If-None-Match, Range
content-type: text/html
content-length: 2284

-k is expected here: first boot generates a self signed certificate for this VM. Replace it with a certificate for your own domain in Step 7.

Run the shipped self test for a single line covering all of it:

sudo /usr/local/sbin/ferron-selftest

Expected output:

ferron-selftest OK: static request on :80 returned the served page (marker + X-Served-By: ferron); /backend/ returned the loopback-only backend's page through Ferron (marker + X-Served-By: ferron); HTTPS on :443 served the per-VM certificate; the demo backend is refused off-box while Ferron is reachable; no stray key material in /etc/ferron

A real request through Ferron: HTTP 200 on the static site carrying Ferron's own server header and the x-served-by header, the /backend/ path returning the loopback-only backend's page and marker through the reverse proxy, the same site over TLS with HTTP/2 negotiated, and the shipped ferron-selftest confirming both static serving and proxying in one line

From your own workstation, the same request against the VM's public address returns the same page. Substitute your VM's IP:

curl -i http://<your-vm-public-ip>/

Step 5: Read this VM's endpoint summary

First boot writes a summary of this VM's endpoints and file locations. It is root only, mode 0600, and it holds no password because Ferron has none.

sudo cat /root/ferron-endpoints.txt | head -12

Expected output (your address will differ):

# Ferron — web server + reverse proxy. Generated on first boot. Unique to THIS VM.
#
ferron.host=20.124.242.74
ferron.url=http://20.124.242.74:80/
ferron.url_tls=https://20.124.242.74:443/
ferron.url_proxy_demo=http://20.124.242.74:80/backend/
ferron.http_port=80
ferron.https_port=443
ferron.config=/etc/ferron/ferron.kdl
ferron.webroot=/var/www/ferron
ferron.tls_cert=/etc/ferron/tls/cloudimg.crt
ferron.selftest=/usr/local/sbin/ferron-selftest

Step 6: Serve your own site and proxy your own backends

The whole configuration is one KDL file, /etc/ferron/ferron.kdl. It declares a globals block for server wide settings and one block per virtual host (:80 and :443 here). Inside a host you set the static root, response headers and any per path location blocks.

cat /etc/ferron/ferron.kdl

Expected output:

globals {
  log "/var/log/ferron/access.log"
  error_log "/var/log/ferron/error.log"
}

:80 {
  root "/var/www/ferron"
  header "X-Served-By" "ferron"
  header "X-Content-Type-Options" "nosniff"

  // Reverse-proxy the /backend path to the loopback-only demo backend.
  location "/backend" remove_base=#true {
    proxy "http://127.0.0.1:8081/"
    header "X-Served-By" "ferron"
  }
}

:443 {
  tls "/etc/ferron/tls/cloudimg.crt" "/etc/ferron/tls/cloudimg.key"
  root "/var/www/ferron"
  header "X-Served-By" "ferron"
  header "X-Content-Type-Options" "nosniff"

  location "/backend" remove_base=#true {
    proxy "http://127.0.0.1:8081/"
    header "X-Served-By" "ferron"
  }
}

To serve your own website, drop your files into /var/www/ferron (or change the root path). To proxy your own backend, change the proxy URL in the /backend location to your server, for example proxy "http://10.0.1.10:8080/". Ferron can balance across several backends — list the proxy directive more than once and it load balances between them.

Apply configuration changes with a reload, which is graceful and does not drop connections:

sudo systemctl reload ferron

The single KDL configuration file that drives Ferron: a globals block for log paths, and two virtual host blocks for ports 80 and 443, each setting the static root, response headers and a per path location that reverse proxies the /backend path to the loopback demo backend, with the 443 host using an explicit manual TLS certificate

Once your own backend is serving, remove the demo backend, which exists only so the appliance can prove itself on a bare VM:

sudo systemctl disable --now ferron-backend

Step 7: Install your own TLS certificate

First boot generated a self signed certificate for this VM. To serve your own domain, replace the pair and reload — the paths are already referenced by the tls directive in ferron.kdl, so nothing else needs editing:

sudo install -o root -g ferron -m 0644 fullchain.pem /etc/ferron/tls/cloudimg.crt
sudo install -o root -g ferron -m 0640 privkey.pem   /etc/ferron/tls/cloudimg.key
sudo systemctl reload ferron

Confirm which certificate the listener is actually serving:

echo | openssl s_client -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -subject -dates

Expected output (the per instance certificate generated at first boot):

subject=C = GB, O = cloudimg, CN = ubuntu-ferron-build-08080514-470326
notBefore=Aug  8 05:44:38 2026 GMT
notAfter=Aug  5 05:44:38 2036 GMT

Prefer automatic TLS? If you point a real domain at this VM, replace the :443 host label with your domain name (for example www.example.com) and remove the tls line — Ferron will obtain and renew a certificate automatically via Let's Encrypt. The explicit :443 label in the shipped config deliberately uses manual TLS because an appliance has no domain yet.

Step 8: Logs and the loopback-only backend

Per request logging is on by default and lands in /var/log/ferron/access.log:

sudo tail -2 /var/log/ferron/access.log

Expected output:

::ffff:10.0.0.16 - - [08/Aug/2026:05:45:43 +0000] "GET / HTTP/1.1" 200 2284 "-" "curl/8.5.0"
::ffff:127.0.0.1 - - [08/Aug/2026:05:46:00 +0000] "GET / HTTP/1.1" 200 2284 "-" "curl/8.5.0"

The bundled demo backend is kept off every non loopback interface, enforced in the kernel with an nftables rule:

sudo nft list table inet cloudimg_ferron

Expected output:

table inet cloudimg_ferron {
    chain input {
        type filter hook input priority filter; policy accept;
        ip daddr 127.0.0.0/8 tcp dport 8081 accept
        ip6 daddr ::1 tcp dport 8081 accept
        tcp dport 8081 drop
    }
}

Matching the destination address rather than the input interface is deliberate: Linux routes traffic addressed to the VM's own routable address over the loopback interface, so an interface based rule would have left the port open on the VM's real addresses while still appearing to pass a 127.0.0.1 test. You can prove the restriction from the VM itself — the backend answers on loopback and times out on this VM's own private address, while Ferron answers on both:

PRIV=$(hostname -I | awk '{print $1}')
echo "backend via loopback : $(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8081/)"
echo "backend via ${PRIV} : $(curl -s -o /dev/null -w '%{http_code}' -m 5 http://${PRIV}:8081/)"
echo "ferron  via ${PRIV} : $(curl -s -o /dev/null -w '%{http_code}' -m 8 http://${PRIV}/)"

Expected output — 000 is curl reporting that the connection never completed, which is the point:

backend via loopback : 200
backend via 10.0.0.16 : 000
ferron  via 10.0.0.16 : 200

The Ferron access log recording served requests, the nftables rule that keeps the demo backend port on loopback addresses only matched on destination address, and a live reachability check from the VM showing the backend answering on loopback but timing out on the VM's routable address while Ferron itself answers on both

Troubleshooting

The service will not start after a reboot. ferron.service will not start until /var/lib/cloudimg/ferron-bootstrap-ready exists, which first boot creates after generating this VM's TLS certificate. Check sudo systemctl status ferron-firstboot.service and sudo cat /var/log/cloudimg-firstboot.log.

A configuration change did not take. Ferron validates its configuration on start. If a reload fails, check the unit and the error log:

sudo systemctl status ferron.service
sudo tail -20 /var/log/ferron/error.log

HTTPS returns a certificate warning. Expected until you install a certificate for your own domain (Step 7) — first boot generates a self signed certificate. Use curl -k while testing.

The proxied path returns 502 or 503. Ferron cannot reach the backend named in the proxy directive. Confirm the backend address and port are correct and reachable from the VM, then sudo systemctl reload ferron.

Requests are logged with the wrong client IP. If Ferron sits behind another proxy or an Azure Load Balancer, set trust_x_forwarded_for in the host block so the real client address is taken from the forwarded header.

Security Notes

  • No default credentials of any kind. Ferron the web server is a stateless file server and request router with no console, no account and no password, so there is nothing to rotate. The static site and reverse proxy on ports 80 and 443 are public by design; add HTTP basic auth (generate hashes with the bundled ferron-passwd), IP allow/block lists or rate limiting from the configuration file when you need them.

  • No key material in the image. The TLS certificate and its private key are generated on your VM at first boot. ferron.service is gated on a marker that first boot creates only after that has happened, so the web server cannot serve HTTPS before it has its own key material. The private key is root:ferron mode 0640.

  • The demo backend is loopback only. It is confined to loopback addresses in the kernel with an nftables rule matched on destination address, and it exists only to demonstrate the reverse proxy — disable it once your own backend is in place.

  • Unprivileged service account. Ferron runs as the ferron user and binds the privileged ports 80 and 443 through the CAP_NET_BIND_SERVICE capability granted by systemd, not by running as root. The unit also sets NoNewPrivileges, ProtectSystem and ProtectHome.

  • Restrict the listeners to the networks you actually serve using NSG rules, and keep 8081 closed.

  • Keep the OS patched. Unattended security upgrades are enabled; apply Ferron updates by replacing /opt/ferron/bin/ferron with a newer upstream release and restarting the service.

Support

cloudimg provides 24/7 support with a guaranteed 24 hour response SLA for all Azure Marketplace images. Contact support@cloudimg.co.uk.

Upstream project documentation is at ferron.sh and the source is at github.com/ferronweb/ferron.