Serving Ruby apps with Caddy as a reverse proxy
posted by Ayush Newatia
24 July, 2026
In this post, we’ll cover how to serve a Rack app using Caddy as a reverse proxy. We won’t be covering how to deploy code to the server as yet. We’ll create the Rack app on the server to handle basic HTTP requests.
This guide builds upon the server setup in Part 1 of this series.
You’ll need a domain name that you own, and can point at your server to follow along with this guide.
Why use a reverse proxy?
Ruby apps are conventionally served through a reverse proxy. Popular Ruby app servers such as Puma aren’t focused on being web servers. Their focus is on running Ruby code, and not on web server features like load balancing, SSL termination, response compression, or serving static assets. While Ruby servers may have those features, it’s not their primary focus, and hence they’re fiddly to use.
A reverse proxy handles all the web server related tasks like SSL termination and serving static files, while handing off HTTP requests to the application server. Most popular Ruby app servers also don’t support HTTP/2, which provides a significant performance gain when serving static files.
Installing Caddy
Caddy is a fantastic new-ish web server that can also be used as a reverse proxy. The documentation is accessible and thorough, and it can be configured with ease unlike older alternatives like Nginx.
SSH to your server as the admin user:
$ ssh admin@<ip-address>
Caddy can be installed using the system package manager (in this case apt, since we’re on Ubuntu). However, that also installs a systemd service, so I prefer downloading the binary to customise the service to my preference.
# Update the apt source for Caddy
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor --no-tty --batch --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
# Download and install the Caddy binary
mkdir ~/tmp
cd ~/tmp
apt-get download caddy
mkdir -p caddy-unpack
dpkg -x $(find caddy*.deb) ~/tmp/caddy-unpack
sudo mv ~/tmp/caddy-unpack/usr/bin/caddy /usr/local/bin/caddy
cd ~/
rm -rf ~/tmp
Caddy needs to bind to the standard ports 80 and 443 to serve HTTP and HTTPS traffic. These are privileged ports, meaning only processes running as root (or with elevated privileges using sudo) can bind to them. Alternatively, a process has to be given access to bind to those ports.
As such, the Caddy systemd service has to be created using the admin user with elevated privileges, even though it will run under the deploy user. The service will contain a definition giving the process the releavant permissions.
But, before we can install the service, let’s create a Caddyfile containing a base configuration as the server can’t start without a config file. The Caddyfile needs to be owned by and under the deploy user’s home directory so it can be modified without connecting as admin.
Instead of reconnecting as deploy, create the config files under the deploy user using sudo, and change their ownership:
sudo mkdir -p /home/deploy/http
sudo touch /home/deploy/http/Caddyfile
sudo touch /home/deploy/http/caddy.global
sudo cat <<STRING >> /home/deploy/http/caddy.global
{
servers {
protocols h1 h2
}
}
STRING
sudo cat <<STRING >> /home/deploy/http/Caddyfile
import caddy.global
import */Caddyfile
STRING
sudo chown deploy:deploy -R /home/deploy
caddy.global contains global settings that apply across all websites on the server. I restrict it to HTTP/1 and HTTP/2 because I find HTTP/3 causes weird connection issues in browsers in between deploys.
The Caddyfile is the main configuration file we’ll pass into Caddy. It loads the global settings and the individual Caddyfiles of all our websites.
The Caddy configuration is held in memory, so if you deploy a new site and need Caddy to pick up it’s config, you’ll need to reload the service. Caddy handles this gracefully with zero downtime.
Then, give the deploy user just enough sudo access to reload and monitor the caddy service.
sudo touch /etc/sudoers.d/deploy
cat << STRING >> /etc/sudoers.d/deploy
deploy $(cat /etc/hostname)=(root) NOPASSWD: /bin/systemctl reload caddy
deploy $(cat /etc/hostname)=(root) NOPASSWD: /bin/systemctl status caddy
STRING
Next, create the Caddy service file:
sudo cat << STRING > /etc/systemd/system/caddy.service
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target
[Service]
Type=notify
User=deploy
ExecStart=/usr/local/bin/caddy run --environ --config /home/deploy/http/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /home/deploy/http/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
STRING
The AmbientCapabilities gives it access to bind to ports 80 and 443 to serve HTTP and HTTPS requests. This access can only be given by a user with elevated privileges, which is why we can’t create this service within the deploy user.
The Ruby app’s service will be created in the deploy user as it doesn’t need elevated privileges.
Enable and start the service:
$ sudo systemctl daemon-reload && sudo systemctl enable --now caddy
Caddy should have started running. Verify it with:
$ sudo systemctl status caddy
$ curl -kv http://localhost:2019/config/
The Caddyfile is blank, so it won’t serve anything yet. Let’s create an app for it to serve.
A basic Rack app
Disconnect your SSH session and reconnect as the deploy user:
$ ssh deploy@<ip-address>
Create a folder for your site named after your domain name:
$ mkdir -p ~/http/example.com
Create a Caddyfile specific to this site, and scaffold an empty Ruby project:
$ cd ~/http/example.com
$ touch Caddyfile
$ bundle init
$ touch config.ru
$ bundle add rack falcon
We’re using Falcon as our server here, but you can use Puma if you wish.
Open up a text editor (I use vim), and 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:
$ touch falcon.rb
# falcon.rb
#!/usr/bin/env falcon-host
require "falcon/environment/rack"
service "example.com" do
include Falcon::Environment::Rack
rackup_path File.expand_path("./config.ru", root)
ipc_path File.expand_path("./application.sock", root)
scheme "http"
protocol Async::HTTP::Protocol::HTTP
end
The Falcon configuration sets binds the app to a Unix socket at application.sock. It also sets the scheme as http since Caddy handles SSL termination, and the protocol declaration automatically chooses between HTTP/1.1 and HTTP/2 depending on the request.
We run the app on a Unix socket to avoid port clashes with other services. When there are multiple web apps, or multiple instances of an app running on a single server, managing port numbers between all of them would be impossible.
At this point, you can do a quick test to check the app can be run:
$ bundle exec falcon host falcon.rb
Create a second SSH session, change to the site’s directory, and use curl to check if the server is running:
$ curl --unix-socket application.sock http://localhost/
If the request succeeds, close the second SSH session, go back to the first one, and use Ctrl+C to stop the server. Now that we know it works, we can create a systemd service to start it automatically and keep it running.
Running the app using a systemd service
Create the service file:
$ mkdir -p ~/.config/systemd/user
$ touch ~/.config/systemd/user/example.com.service
Add the below code block to the service file:
[Unit]
Description=example.com Web Service
After=network.target
[Service]
Type=notify
NotifyAccess=all
User=deploy
WorkingDirectory=/home/deploy/http/example.com
ExecStart=/usr/bin/env bash -lc 'bundle exec falcon host falcon.rb'
KillMode=mixed
TimeoutStopSec=60
Restart=always
[Install]
WantedBy=default.target
A couple of things to note in the above service:
bash -lcis used to start the server. This loads~/.profileensuringchrubyloads and the environment variables pointing to the Ruby binary and Gems are set.KillMode=mixedsendsSIGTERMonly to the main process when the service is stopped. This allows the server to stop gracefully without dropping requests. If it fails to exit within the timeout, thenSIGKILLwill be sent to the main process as well as child processes.
Start the service:
$ systemctl --user enable --now example.com
Check that it is running using curl as we did earlier. We can’t access this via the internet as yet because we haven’t pointed Caddy to it.
Configuring the Caddy reverse proxy
Configure Caddy to act as a reverse proxy to the Unix socket for your domain name:
# example.com/Caddyfile
example.com {
reverse_proxy unix///home/deploy/http/example.com/application.sock
}
Reload Caddy:
$ sudo systemctl reload caddy
You can do this as the deploy user despite it being a sudo command because we gave it specific access for it earlier in this guide.
Your Rack app should now be available at your domain!
Conclusion
Rack underpins every popular Ruby web framework and server, and hence you should be able to use this same techniqe to serve apps built with Rails, Hanami, Roda, or any other framework.
In the next part, we’ll look at an alternate approach where Falcon itself is internet facing, and used for SSL termination and as a reverse proxy for multiple websites. We’ll also discuss why I don’t recommend that approach.