Web Server #

The Ruby web ecosystem is built on a single abstraction that unifies everything: Rack. Rack is the standard interface between web servers (Puma, Unicorn, WEBrick) and Ruby web applications (Rails, Sinatra, or even pure Rack apps). Because all Ruby servers and frameworks speak “the Rack language,” you can swap servers without changing a single line of application code. This article covers Rack as the foundation, an in-depth comparison of every web server, how to configure Puma for production, reverse proxy architecture with Nginx, zero-downtime deployment, and security you can’t afford to ignore.

Rack — The Foundation of All Ruby Web #

Rack defines a simple contract: a Rack app is any Ruby object that responds to the call method with an env argument (a Hash containing request information) and returns a three-element Array [status, headers, body].

# The simplest Rack application ever
app = lambda do |env|
  [
    200,                                          # HTTP status code
    { "Content-Type" => "text/plain" },           # Response headers
    ["Hello from a Rack application!"]            # Response body (Enumerable)
  ]
end

# Run with any server that supports Rack
# Save as config.ru and run: rackup
# config.ru — the standard configuration file for all Rack applications
# Rack reads this file when the server starts

# A Rack application as a class
class SimpleApp
  def call(env)
    request  = Rack::Request.new(env)
    response = Rack::Response.new

    case request.path
    when "/"
      response.write "Welcome!"
      response.status = 200
    when "/about"
      response.write "About us"
      response.status = 200
    else
      response.write "404 - Page not found"
      response.status = 404
    end

    response.finish
  end
end

run SimpleApp.new
# Run with rackup (uses WEBrick by default)
rackup config.ru

# Specify the server and port
rackup config.ru --server puma --port 3000

# Production mode
rackup config.ru -E production -p 80

Rack Middleware #

Middleware is a layer that “wraps” the application — processing requests before they reach the app and/or modifying responses before they’re sent to the client. This is a very powerful pattern used extensively by Rails:

# Custom middleware
class LoggingMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    start = Time.now
    status, headers, body = @app.call(env)   # pass through to the app
    duration = ((Time.now - start) * 1000).round(2)

    puts "[#{Time.now.strftime('%H:%M:%S')}] #{env['REQUEST_METHOD']} " \
         "#{env['PATH_INFO']}#{status} (#{duration}ms)"

    [status, headers, body]
  end
end

class AuthMiddleware
  def initialize(app, required_token)
    @app           = app
    @required_token = required_token
  end

  def call(env)
    token = env["HTTP_AUTHORIZATION"]&.gsub("Bearer ", "")

    if token == @required_token
      @app.call(env)
    else
      [
        401,
        { "Content-Type" => "application/json" },
        ['{"error":"Unauthorized"}']
      ]
    end
  end
end

# Assemble the middleware stack in config.ru
use LoggingMiddleware
use AuthMiddleware, ENV["API_TOKEN"]
run SimpleApp.new
flowchart TD
    A[HTTP Request] --> B[LoggingMiddleware]
    B --> C[AuthMiddleware]
    C --> D[RateLimitMiddleware]
    D --> E[Rack App]
    E --> D2[RateLimitMiddleware]
    D2 --> C2[AuthMiddleware]
    C2 --> B2[LoggingMiddleware]
    B2 --> F[HTTP Response]

Ruby Web Server Comparison #

WEBrick:
  Model:    Single-threaded (Ruby 1.x), multi-thread (Ruby 2+)
  Best for: Local development, learning, prototypes
  Avoid:    Any production use

Puma:
  Model:    Multi-thread + multi-process (cluster mode)
  Best for: Production Rails and Sinatra, thread-safe apps
  Standard: Default for Rails 5+ and Heroku

Unicorn:
  Model:    Pre-fork multi-process, single-thread per worker
  Best for: CPU-bound apps, non-thread-safe environments
  Fits:     When you need full isolation between requests

Passenger (mod_rack):
  Model:    Multi-process, integrated with Nginx/Apache
  Best for: Traditional VPS deployments, easy configuration
  Bonus:    Built-in monitoring panel, automatic rolling restart

Falcon:
  Model:    Async/fiber-based, HTTP/2 native
  Best for: Apps with lots of concurrent I/O
  New:      The ecosystem is still evolving

WEBrick — The Built-in Development Server #

WEBrick is part of Ruby’s standard library and needs no additional installation. It’s suitable for learning and light development:

require 'webrick'

# Basic HTTP server with WEBrick
server = WEBrick::HTTPServer.new(
  Port:            8080,
  DocumentRoot:    Dir.pwd,
  Logger:          WEBrick::Log.new("/dev/null"),   # turn off logging
  AccessLog:       []
)

# Add a servlet (handler) for a specific path
server.mount_proc "/api/hello" do |req, res|
  res.content_type = "application/json"
  res.body = JSON.generate({
    message: "Hello!",
    time: Time.now.iso8601,
    method: req.request_method,
    path: req.path
  })
end

server.mount_proc "/api/user" do |req, res|
  case req.request_method
  when "GET"
    res.body = JSON.generate({ id: 1, name: "Rina" })
  when "POST"
    data = JSON.parse(req.body)
    res.status = 201
    res.body = JSON.generate({ success: true, data: data })
  else
    res.status = 405
    res.body = "Method Not Allowed"
  end
end

# Handle Ctrl+C
trap("INT") { server.shutdown }

puts "WEBrick running on http://localhost:8080"
server.start

Puma — The Standard Rails Production Server #

Puma is the recommended web server for Rails in production. It uses a hybrid model: multiple worker processes (forked), each with multiple threads.

# Gemfile
gem "puma", "~> 6.4"

Puma Configuration for Production #

# config/puma.rb

# === Thread Configuration ===
# Each worker has thread_count threads
# Threads are useful for I/O-bound tasks (DB queries, HTTP requests)
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count

# === Worker Configuration (Cluster Mode) ===
# Workers = forked processes, usually one per CPU core
# Workers are useful for CPU-bound tasks and memory isolation
workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))

# === Preload App ===
# Load the app before forking workers — more efficient (Copy-on-Write)
# REQUIRED if using workers > 1
preload_app!

# === Server Socket ===
port ENV.fetch("PORT", 3000)
environment ENV.fetch("RACK_ENV", "development")

# === Callbacks ===
on_worker_boot do
  # Re-establish the database connection after forking
  # Important! Connections can't be shared between processes
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

on_worker_shutdown do
  # Clean up resources before a worker is shut down
  ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord)
end

before_fork do
  # Runs before forking occurs
  # Close connections that aren't needed before forking
end

# === Timeout ===
# A worker that doesn't respond within this time is killed and restarted
worker_timeout 60

# === Bind via Unix Socket (faster than TCP) ===
# Use this if Nginx runs on the same machine
bind "unix:///tmp/puma.sock"
# or TCP:
# bind "tcp://0.0.0.0:3000"

# === Logging ===
stdout_redirect "log/puma.stdout.log", "log/puma.stderr.log", true

# === PID File ===
pidfile "tmp/pids/puma.pid"
state_path "tmp/pids/puma.state"

# === Plugin ===
plugin :tmp_restart   # restart with `touch tmp/restart.txt`
# Run Puma
bundle exec puma -C config/puma.rb

# Run as a daemon (background)
bundle exec puma -C config/puma.rb --daemon

# Graceful restart (without downtime)
bundle exec pumactl restart

# Worker status
bundle exec pumactl stats

# Hot reload (phased restart — one worker at a time)
bundle exec pumactl phased-restart

How Puma Cluster Mode Works #

Master Process
├── Worker 1 (PID: 1234) — 5 threads
│   ├── Thread 1: Serving a request
│   ├── Thread 2: Waiting on a DB query
│   ├── Thread 3: Idle
│   ├── Thread 4: Waiting on an external HTTP response
│   └── Thread 5: Idle
├── Worker 2 (PID: 1235) — 5 threads
│   └── (same as Worker 1)
└── Worker 3 (PID: 1236) — 5 threads
    └── (same as Worker 1)

Total concurrent capacity: 3 workers × 5 threads = 15 simultaneous requests

Unicorn — Pre-fork Multi-process #

Unicorn uses the pre-fork model: a master process forks a number of workers before any requests arrive. Each worker is an independent process handling one request at a time.

# Gemfile
gem "unicorn", "~> 6.1"
# config/unicorn.rb

# Number of worker processes — usually one per CPU core
worker_processes Integer(ENV.fetch("WEB_CONCURRENCY", 4))

# Application working directory
working_directory "/var/www/app"

# Socket where Unicorn listens
listen "/tmp/unicorn.sock", backlog: 64
listen 3000, tcp_nopush: true

# Timeout before a worker is killed
timeout 30

# PID file
pid "/var/www/app/tmp/pids/unicorn.pid"

# Logs
stderr_path "log/unicorn.stderr.log"
stdout_path "log/unicorn.stdout.log"

# Preload the app for Copy-on-Write efficiency
preload_app true

# Callback after forking a new worker
after_fork do |server, worker|
  # Re-establish connections after forking
  if defined?(ActiveRecord::Base)
    ActiveRecord::Base.establish_connection
  end

  # Close the Redis connections inherited from the master
  if defined?(Sidekiq)
    Sidekiq.redis_pool.shutdown { |conn| conn.disconnect! }
  end
end

before_fork do |server, worker|
  # Disconnect the DB connection in the master before forking
  if defined?(ActiveRecord::Base)
    ActiveRecord::Base.connection.disconnect!
  end
end
# Run Unicorn
bundle exec unicorn -c config/unicorn.rb

# Zero-downtime restart with USR2
kill -USR2 $(cat tmp/pids/unicorn.pid)

# Graceful shutdown
kill -QUIT $(cat tmp/pids/unicorn.pid)

# Reduce the number of workers (without restart)
kill -TTOU $(cat tmp/pids/unicorn.pid)

# Increase workers (without restart)
kill -TTIN $(cat tmp/pids/unicorn.pid)

Nginx as a Reverse Proxy #

In production, Puma or Unicorn is never exposed directly to the internet. Nginx stands in front as a reverse proxy handling client connections, SSL termination, static files, and forwarding requests to the Ruby app:

Internet → Nginx (port 443/80) → Puma/Unicorn (Unix socket / port 3000)
# /etc/nginx/sites-available/myapp

upstream puma_app {
  # Faster than TCP because there's no network overhead
  server unix:///tmp/puma.sock fail_timeout=0;
}

# Redirect HTTP to HTTPS
server {
  listen 80;
  server_name example.com www.example.com;
  return 301 https://$server_name$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  # SSL Certificate (use Let's Encrypt / Certbot)
  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_ciphers         HIGH:!aNULL:!MD5;

  root /var/www/app/public;

  # Gzip compression
  gzip on;
  gzip_types text/plain application/json application/javascript text/css;

  # Serve static files directly from Nginx — far faster!
  location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
  }

  # Rails static error pages
  error_page 500 502 503 504 /500.html;
  error_page 404 /404.html;

  location / {
    try_files $uri/index.html $uri @puma;
  }

  location @puma {
    proxy_pass http://puma_app;

    # Important headers for Rails applications
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header Host              $http_host;

    proxy_redirect off;
    proxy_read_timeout    300;
    proxy_connect_timeout 300;
    proxy_send_timeout    300;

    # Buffer settings
    proxy_buffering    on;
    proxy_buffer_size  4k;
    proxy_buffers      8 4k;
  }

  # Limit the upload size
  client_max_body_size 10M;
  keepalive_timeout    10;

  # Security headers
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-XSS-Protection "1; mode=block" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# Test the Nginx configuration
nginx -t

# Reload the configuration without restarting (zero-downtime)
nginx -s reload

# Enable the site
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
nginx -s reload

Zero-Downtime Deployment #

Deployment without downtime is a must for production applications. Here are several strategies:

Puma Phased Restart #

# Phased restart — restart workers one at a time
# During the process, there's always a worker ready to serve requests
bundle exec pumactl phased-restart

# Or with a signal
kill -SIGUSR1 $(cat tmp/pids/puma.pid)

Unicorn Hot Reload (USR2) #

# 1. Send USR2 — a new master is forked, the old master keeps running
kill -USR2 $(cat tmp/pids/unicorn.pid)

# 2. The new master loads the latest code and forks new workers
# 3. Once the new master is ready, send WINCH to the old master
#    (gracefully shuts down the old workers)
kill -WINCH $(cat tmp/pids/unicorn.pid.oldbin)

# 4. If successful, send QUIT to the old master
kill -QUIT $(cat tmp/pids/unicorn.pid.oldbin)

# If there are problems, roll back: send HUP to the old master
kill -HUP $(cat tmp/pids/unicorn.pid.oldbin)

Deployment with Capistrano #

# Capfile
require "capistrano/setup"
require "capistrano/deploy"
require "capistrano/rbenv"
require "capistrano/bundler"
require "capistrano/rails"
require "capistrano/puma"

install_plugin Capistrano::Puma

# config/deploy.rb
set :application, "myapp"
set :repo_url,    "[email protected]:account/myapp.git"
set :deploy_to,   "/var/www/myapp"

set :rbenv_ruby, File.read(".ruby-version").strip
set :bundle_flags, "--deployment --quiet"

# Symlink files that don't go into Git
set :linked_files, %w[.env config/master.key]
set :linked_dirs,  %w[log tmp/pids tmp/cache tmp/sockets public/uploads]

namespace :deploy do
  after :finishing, "puma:restart"
end
# Deploy to production
bundle exec cap production deploy

# Roll back to the previous version
bundle exec cap production deploy:rollback

Web Server Monitoring #

# Puma stats endpoint — enable in config/puma.rb
activate_control_app "unix:///tmp/pumactl.sock"

# Or via a plugin
plugin :tmp_restart
# Check Puma status
bundle exec pumactl stats

# Example output:
# {"started_at":"2024-08-15T07:30:00Z",
#  "workers":2,
#  "phase":0,
#  "booted_workers":2,
#  "old_workers":0,
#  "worker_status":[
#    {"started_at":"...","pid":1234,"index":0,"phase":0,
#     "booted":true,"last_checkin":"...","last_status":{
#       "backlog":0,"running":3,"pool_capacity":2,"max_threads":5
#     }
#    }
#  ]}

# Monitor with systemd
# /etc/systemd/system/puma.service
# /etc/systemd/system/puma.service
[Unit]
Description=Puma HTTP Server for MyApp
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
ExecStart=/home/deploy/.rbenv/bin/rbenv exec bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
RestartSec=5

# Environment
Environment=RAILS_ENV=production
EnvironmentFile=/var/www/myapp/shared/.env

# Limits
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
# Manage with systemd
systemctl enable puma
systemctl start puma
systemctl status puma
systemctl restart puma

# View logs
journalctl -u puma -f

Building a Simple Rack Application (Without a Framework) #

Pure Rack is suitable for microservices or simple APIs that don’t need all of Rails:

# app.rb — a framework-free Rack API
require 'rack'
require 'json'

class Router
  def initialize
    @routes = {}
  end

  def get(path, &handler)
    @routes[["GET", path]] = handler
  end

  def post(path, &handler)
    @routes[["POST", path]] = handler
  end

  def call(env)
    request  = Rack::Request.new(env)
    key      = [request.request_method, request.path_info]
    handler  = @routes[key]

    if handler
      begin
        result = handler.call(request)
        [200, { "Content-Type" => "application/json" }, [JSON.generate(result)]]
      rescue => e
        [500, { "Content-Type" => "application/json" },
         [JSON.generate({ error: e.message })]]
      end
    else
      [404, { "Content-Type" => "application/json" },
       [JSON.generate({ error: "Route not found: #{request.path_info}" })]]
    end
  end
end

# Define routes
api = Router.new

api.get "/health" do |req|
  { status: "ok", time: Time.now.iso8601 }
end

api.get "/products" do |req|
  [
    { id: 1, name: "Laptop",   price: 15_000_000 },
    { id: 2, name: "Mouse",    price:    350_000 },
    { id: 3, name: "Keyboard", price:    450_000 }
  ]
end

api.post "/products" do |req|
  data = JSON.parse(req.body.read)
  { id: rand(100..999), **data.transform_keys(&:to_sym) }
end

# config.ru
use Rack::CommonLogger   # log every request
use Rack::Deflater       # automatic gzip compression
run api

Web Server Security #

Ruby deployment security checklist:
  ✓ Always use HTTPS — Let's Encrypt is free and automatic
  ✓ Security headers in Nginx (X-Frame-Options, CSP, HSTS)
  ✓ Rate limiting in Nginx to prevent brute force and DDoS
  ✓ Limit request size (client_max_body_size)
  ✓ Don't expose the Puma/Unicorn port directly to the internet
  ✓ Run the app as a non-root user
  ✓ Update gems regularly — bundle audit
  ✓ Rails credentials encrypted, not plain text in ENV
  ✓ Enable a firewall (ufw/iptables) — only ports 80 and 443
  ✓ Log monitoring — look for suspicious access patterns
# Rate limiting in Nginx
http {
  # Define a rate limit zone: 10 requests/second per IP
  limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

  server {
    location /api/ {
      # Allow a burst of 20 requests, the rest are rejected with 429
      limit_req zone=api_limit burst=20 nodelay;
      limit_req_status 429;
      proxy_pass http://puma_app;
    }
  }
}

Choosing the Right Web Server #

flowchart TD
    A[Choose a Web Server] --> B{Environment?}
    B --> C[Development]
    B --> D[Production]
    C --> E["WEBrick or Puma\n(rails server is enough)"]
    D --> F{Application type?}
    F --> G["Rails / Sinatra\nThread-safe"]
    F --> H["Legacy / Not thread-safe\nor CPU-intensive"]
    F --> I["Integrated with Nginx/Apache\nTraditional deployment"]
    G --> J["Puma\nDefault and recommended"]
    H --> K["Unicorn\nPre-fork, full isolation"]
    I --> L["Passenger\nEasy setup, built-in monitoring"]
    J --> M["Cluster mode\n2-4 workers × 5 threads"]
    K --> N["4-8 worker processes\nwithout threading"]

Summary #

  • Rack is the foundation of everything — all Ruby web servers and frameworks speak Rack; understanding call(env) → [status, headers, body] is the key to understanding the Ruby web ecosystem.
  • The middleware stack is Rack’s power — logging, authentication, rate limiting, and compression can be added as middleware without changing application code.
  • Puma is the standard for production Rails — cluster mode with 2-4 workers and 5 threads per worker is a good configuration for a 2-4 core server.
  • preload_app! is required in cluster mode — loading the app before forking saves memory and speeds up worker startup through Copy-on-Write.
  • Re-establish DB connections after forking — in on_worker_boot (Puma) or after_fork (Unicorn); connections can’t be shared between processes.
  • Unix sockets are faster than local TCP — for Nginx ↔ Puma/Unicorn communication on the same machine, use unix:///tmp/puma.sock.
  • Nginx in front for static files and SSL — Nginx serves static assets far more efficiently than Ruby; SSL termination in Nginx also reduces the app’s load.
  • Puma phased restart for zero-downtimepumactl phased-restart or kill -SIGUSR1 restarts workers one at a time without dropping them all at once.
  • systemd for process management — more reliable than nohup or screen; ensures the server restarts automatically after a reboot or crash.
  • Rate limiting in Nginx, not in the app — more efficient because requests are rejected before they ever touch a Ruby process.

← Previous: WebSocket   Next: Unit Testing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact