Deployment guide

Ferron

Overview

Ferron is a fast, memory-safe web server written in Rust, and a lean, modern alternative to nginx and Caddy. It 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, where you declare virtual hosts, per-path locations, proxying, header rewriting, access control and rate limiting in one place.

This SecureAnyCloud image ships the official upstream Ferron 2.8.1 release binary on Ubuntu 24.04 on Azure. It is 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, and binds the privileged ports 80 and 443 through a granted Linux capability instead of running as root.

The web server is serving the moment the VM 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 SecureAnyCloud landing page over both HTTP and HTTPS. 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 and reachable only over loopback, so a response 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. Baking one in would put the same private key on every customer VM, so 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 that it cannot start before that bootstrap has completed.

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

What you get:

  • 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/ that points 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: ferron-passwd (password hashes for HTTP basic auth) and ferron-precompress (precompress static assets)
  • An nftables rule that keeps 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 the latest security patches applied at build time
  • Azure Linux Agent for cloud integration and SSH key injection
  • 24/7 SecureAnyCloud support with a guaranteed 24-hour response SLA

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

Prerequisites

  • An active Azure subscription, an SSH public key, and a VNet with a subnet in the target region
  • A 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, because the bundled default site and demo backend let the appliance work 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.
1

Deploy from the Azure Portal

Search the Azure Marketplace for Ferron, select the SecureAnyCloud publisher, and click Create.

Add Network Security Group rules for 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. It is the bundled demo backend, and the image deliberately keeps it private.

2

Deploy from the Azure CLI

az vm image terms accept --urn sac1647283583153:ferron:default:latest
az vm create \
  --resource-group my-ferron-rg \
  --name my-ferron-vm \
  --image sac1647283583153: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
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. Then check the services:

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. It gives the reverse proxy something real to route to before you have added your own backends.

Next, confirm which process owns which port. Ferron holds the two public listeners, and the demo backend is bound to the loopback address only:

sudo ss -lntp | grep ferron

Expected output. Ferron owns :80 and :443, and 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 image records exactly which upstream artifact it shipped. Read the record:

cat /opt/ferron/VERSION
4

Prove Ferron serves and proxies a real request

Run this check first, because it exercises the whole request path and not just a listening socket. Request the static site:

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 is set by Ferron itself, and x-served-by: ferron is added by a header directive in the configuration. Now request the proxied path:

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

Expected output. 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 'SAC_FERRON_BACKEND'

Expected output. Only the loopback backend on 127.0.0.1:8081 serves this marker, so seeing it here proves that Ferron proxied the request to the backend:

SAC_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

The -k flag is expected here, because 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 that covers all of the above:

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

From your own workstation, the same request against the VM's public address returns the same page. Replace the bracketed text with your VM's IP address:

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

Read this VM's endpoint summary

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

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/sac.crt ferron.selftest=/usr/local/sbin/ferron-selftest
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/sac.crt" "/etc/ferron/tls/sac.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, copy 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. A reload is graceful and does not drop connections:

sudo systemctl reload ferron

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

sudo systemctl disable --now ferron-backend
7

Install your own TLS certificate

First boot generated a self-signed certificate for this VM. To serve your own domain, replace the certificate and key pair and reload. The tls directive in ferron.kdl already references these paths, so nothing else needs editing:

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

Confirm which certificate the listener is serving:

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

Expected output, showing the per-instance certificate generated at first boot:

subject=C = GB, O = sac, 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 then obtains and renews a certificate automatically via Let's Encrypt.

The shipped config deliberately uses manual TLS with the explicit :443 label, because an appliance has no domain yet.

8

Logs and the loopback-only backend

Per-request logging is on by default and writes to /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. The kernel enforces this with an nftables rule:

sudo nft list table inet sac_ferron

Expected output:

table inet sac_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 } }

The rule deliberately matches the destination address instead of the input interface. Linux routes traffic addressed to the VM's own routable address over the loopback interface, so an interface-based rule would leave the port open on the VM's real addresses while still passing 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. The value 000 is curl reporting that the connection never completed, which is the intended result:

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

Troubleshooting

The service does not start after a reboot. ferron.service does not start until /var/lib/sac/data/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/sac/sac-firstboot.log.

A configuration change did not take effect. 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. This is expected until you install a certificate for your own domain (Step 7), because 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 that the backend address and port are correct and reachable from the VM, then run 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 that the real client address is taken from the forwarded header.

Security Notes

No default credentials of any kind. Ferron 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 and 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. An nftables rule matched on destination address confines it to loopback addresses in the kernel. It exists only to demonstrate the reverse proxy, so 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. Use NSG rules to limit them to the networks you actually serve, and keep 8081 closed.

Keep the OS patched. Unattended security upgrades are enabled. To update Ferron, replace /opt/ferron/bin/ferron with a newer upstream release and restart the service.

Support

SecureAnyCloud provides 24/7 support with a guaranteed 24-hour response SLA for all Azure Marketplace images. Contact sales@secureanycloud.com.

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

Deploy on Azure

Launch Ferron on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure