Falcon as a web server for Ruby applications
posted by Ayush Newatia
24 July, 2026
In the previous post, we covered how to install and run Caddy, and then use it as a reverse proxy to serve a Rack app, and discussed why this is the conventional configuration.
However, Falcon aims to change this. It has a mode called Falcon Virtual designed specifically to face the internet and perform SSL termination and SNI resolution to serve multiple sites. It also supports HTTP/2 natively. It’s designed for high I/O throughput using Ruby Fibers making it efficient at serving static assets.
That got me thinking that it would be great to serve Ruby apps using Ruby end-to-end. This post describes a strategy to serve a Rack app using Falcon, without an additional reverse proxy. And also, why that’s a really bad idea and I wouldn’t recommend it.
To be clear, Falcon’s great, but the approach of serving a Ruby web app without a reverse proxy is wrong in my opinion, and I’ll explain why at the end.
You’ll need a domain name that you own, and can point at your server to complete this guide.
Initial setup
If you worked through the previous post, and have Caddy installed on your server, stop the Caddy service as it will clash with Falcon. Falcon will need to bind to ports 80 and 443 to serve internet HTTP and HTTPS traffic.
# Run as the `admin` user
$ sudo systemctl stop caddy
Apart from that, this guide builds on the server setup described in Part 1.
Ports lower than 1024 are privileged and can only be bound to by processes with elevated privileges. However, I’d prefer to run Falcon as the deploy user. That way, automated tools can manage it without needing to connect as admin.
Authbind
There’s a utility called authbind which enables processes run by users without elevated privileges to bind to privileged ports.
SSH to your server as admin and install authbind:
$ ssh admin@<ip-address>
$ sudo apt install authbind
Then, we need to create files representing each port we’d like to allow a user to bind to, and give them the execute permission on that file:
$ sudo touch /etc/authbind/byport/80
$ sudo touch /etc/authbind/byport/443
$ sudo setfacl -m u:deploy:rx /etc/authbind/byport/80
$ sudo setfacl -m u:deploy:rx /etc/authbind/byport/443
We create two files for the two HTTP ports and give the deploy user the necessary permissions. Using authbind, we can now run a process under the deploy user that can bind to these ports.
Certbot
While you’re connected as admin, install certbot to issue SSL certificates, as Falcon doesn’t do this automatically.
$ sudo snap install --classic certbot
Exit the SSH session and reconnect as deploy:
$ ssh deploy@<ip-address>
A basic Rack app
Create a new Rack app within a folder named after your domain name:
$ mkdir -p ~/http/example.com
Scaffold an empty Ruby project:
$ cd ~/http/example.com
$ bundle init
$ touch config.ru
$ bundle add rack falcon
Create a basic Rack app as shown below:
# config.ru
class App
def call(env)
[200, { "content-type" => "text/plain" }, ["It is now #{Time.now.utc}"]]
end
end
run App.new
You’ll also need to create a Falcon config file that is executable:
$ touch falcon.rb
$ chmod +x falcon.rb
# falcon.rb
#!/usr/bin/env falcon-host
require "falcon/environment/rack"
service "example.com" do
include Falcon::Environment::Rack
end
Bootstrapping an SSL certificate
This is where things start to get tricky because Falcon doesn’t automatically issue SSL certificates. We’ll use a utility called certbot to issue certificates. Certbot makes a request to Let’s Encrypt, which issues something called an ACME challenge. This is an HTTP request to your server for a file certbot places at a conventional location. It’s used to validate that you own the domain and server.
This leads to a catch-22 problem where we need an SSL certificate (even an invalid one), to be able to start Falcon so it can serve the ACME challenge used to issue the SSL certificate.
We’ll solve this problem by:
- Creating a self-signed SSL certificate and point Falcon at that in the absence of a valid certificate.
- Handle the ACME challenge HTTP request using the self-signed certificate.
Issuing a self-signed certificate
First, update the Falcon config so it looks for a live SSL certificate, and falls back to a self-signed certificate if one doesn’t exist:
# falcon.rb
#!/usr/bin/env falcon-host
# frozen_string_literal: true
require "falcon/environment/rack"
require "falcon/environment/tls"
service "rack.forkhandles.cc" do
include Falcon::Environment::Rack
include Falcon::Environment::TLS
# Required due to https://github.com/socketry/falcon/pull/355
ssl_private_key { OpenSSL::PKey.read(File.read(ssl_private_key_path)) }
# Define a location for the self-signed certs used to bootstrap a valid cert
lets_encrypt_bootstrap { "/home/deploy/.config/letsencrypt/bootstrap" }
# Define a location for the live certificates
lets_encrypt_root { "/home/deploy/.config/letsencrypt/live" }
# Fallback to the self-signed cert if a valid cert doesn't exist
ssl_certificate_path {
live_cert = File.join(lets_encrypt_root, authority, "fullchain.pem")
bootstrap_cert = File.join(lets_encrypt_bootstrap, authority, "fullchain.pem")
if File.exist?(live_cert)
return live_cert
else
return bootstrap_cert
end
}
ssl_private_key_path {
live_pk = File.join(lets_encrypt_root, authority, "privkey.pem")
bootstrap_pk = File.join(lets_encrypt_bootstrap, authority, "privkey.pem")
if File.exist?(live_pk)
return live_pk
else
return bootstrap_pk
end
}
end
Create the directory structure for the certificates, and issue the self-signed certificate.
$ mkdir -p ~/.config/letsencrypt/bootstrap
$ mkdir -p ~/.config/letsencrypt/live
$ mkdir -p ~/.config/letsencrypt/log
$ mkdir -p ~/.config/letsencrypt/lib
$ mkdir ~/.config/letsencrypt/bootstrap/example.com
$ cd ~/.config/letsencrypt/bootstrap/example.com
$ openssl req -x509 -newkey rsa:2048 \
-keyout privkey.pem \
-out fullchain.pem \
-days 365 \
-nodes \
-subj "/CN=example.com"
Responding to the ACME challenge
Next, we need to handle the ACME challenge. Implement a Rack middleware to match and respond to the challenge’s HTTP request:
# config.ru
class AcmeChallenge
MATCHER = /\A\/\.well-known\/acme-challenge\/([\w-]+)/
def initialize(app)
@app = app
end
def call(env)
if match = env["PATH_INFO"].match(AcmeChallenge::MATCHER)
token = match[1]
challenge_file = File.join(__dir__, ".well-known", "acme-challenge", token)
if File.exist?(challenge_file)
[200, {}, [File.read(challenge_file)]]
else
[404, {}, [""]]
end
else
@app.call(env)
end
end
end
class App
def call(env)
[200, { "content-type" => "text/plain" }, ["It is now #{Time.now.utc}"]]
end
end
use AcmeChallenge
run App.new
The middleware will detect a challenge request, and read the file placed by certbot in the conventional location and return it to fulfil the challenge.
We can now finally run Falcon:
$ cd ~
$ authbind --deep falcon virtual /home/deploy/http/**/falcon.rb
The glob pattern should find the falcon.rb files for all sites on the server and start them. It will use the domain specified as the service name in falcon.rb to match requests to the correct site.
With the server running, create a second SSH connection as deploy and run certbot:
$ certbot certonly --webroot --agree-tos --email <your email> -w ~/http/example.com -d example.com --work-dir ~/.config/letsencrypt/lib --logs-dir ~/.config/letsencrypt/log --config-dir ~/.config/letsencrypt/
Certbot should issue your certificate and Falcon should automatically detect it after a few seconds. Once the certificate is issued, certbot will automatically renew it.
Your Rack app should now be accessible from the internet, using your domain name, with a valid SSL certificate.
A systemd service
You can create a systemd service for Falcon Virtual as shown below:
[Unit]
Description=Falcon Virtual
After=network.target
[Service]
Type=notify
NotifyAccess=all
User=deploy
ExecStart=/usr/bin/bash -lc 'authbind --deep falcon virtual %h/http/**/falcon.rb'
Restart=always
[Install]
WantedBy=default.target
Place the above contents in ~/.config/systemd/user/falcon-virtual.service, and then start the service using:
$ systemctl --user enable --now falcon-virtual
Why this setup is a bad idea
If you followed along the previous post which used Caddy, as well as this one, you probably already understand why I don’t recommend this approach.
Beyond the added fiddliness to manage SSL certificates and the ACME challenge, it also makes zero-downtime deploys impossible when upgrading Ruby. Falcon Virtual starts a main process, and then forks off processes for each of your sites. While we can reload code without stopping processes, it’s impossible to change the Ruby binary without stopping the main Falcon Virtual process.
Also, Falcon is an application server. It’s pretty damn great at being an application server. Caddy is a purpose-built web server and reverse proxy. Falcon will always be more tricky to use as a web server than Caddy.
I recommend keeping your reverse proxy independent from your Ruby processes as this is the most flexible setup.
In the next, and final part of this series, we’ll look at deploying Ruby apps to a server.