Articles

Rails recipes, tutorials, and development logs.

September 02, 2026

The Production-Ready Rails Setup: Hatchbox, Cloudflare, and Edge Optimization

Getting a Rails app running on a clean VPS via Hatchbox takes about ten minutes. But getting that app to run blazingly fast, handle traffic surges without spiking CPU, and eliminate common SSL and caching traps requires an intentional partnership between Hatchbox and Cloudflare. Too often, developers slap Cloudflare in front of Rails in default "Orange Cloud" (proxy) mode and run into edge redirection loops, cache poisoning on session cookies, or bloated Puma workers serving redirect loops for media files. Here is the blueprint for pairing Hatchbox with Cloudflare, along with the exact edge rules, proxy configurations, and caching tweaks needed to extract maximum performance from your server. 1. The SSL Handshake & Redirect FoundationHatchbox provisions Caddy out of the box to manage local SSL via Let’s Encrypt. When you place Cloudflare’s proxy in front of Caddy, you introduce two layers of TLS. • Set SSL to Full (Strict): In Cloudflare under SSL/TLS -> Overview, choose Full (Strict). Never use "Flexible." Flexible forces Cloudflare to speak to Caddy over unencrypted HTTP (port 80). Caddy will issue a 301 back to HTTPS, causing an infinite redirect loop (ERR_TOO_MANY_REDIRECTS). Strict ensures end-to-end encrypted validation against Caddy's certificates. • Apex to WWW (or WWW to Apex) at the Edge: Never make Puma or Rack handle domain rewrites. Offload it entirely to Cloudflare using Redirect Rules (under Rules $\rightarrow$ Redirect Rules): • The Prerequisite DNS Record: Cloudflare's edge rules only evaluate if traffic actually reaches Cloudflare. Ensure you have a proxied (Orange Cloud) record for www in your DNS settings—either a CNAME pointing www to @ (your apex domain), or an A record pointing to your server's IP. If this record is missing or gray-clouded, browsers will throw NXDOMAIN before the rule ever executes. • Rule: If Hostname equals "[www.yourdomain.com](https://www.yourdomain.com)" • Action: Dynamic 301 redirect to concat("[https://yourdomain.com](https://yourdomain.com)", http.request.uri.path) • This resolves the canonical redirect at the edge in single-digit milliseconds, saving your VPS from ever handling a useless handshake. 2. Taming Active Storage with Proxy Mode & Edge Cache RulesBy default, Active Storage uses redirect mode: a client requests an asset, your Rails process boots, generates a short-lived signed S3 or bucket URL, and issues a 302 redirect. If you have 50 avatars or attachments on a page, your Puma threads spend all their bandwidth negotiating signed redirects. Enable Proxy Mode in RailsBy default, Active Storage runs in redirect mode: when a client requests an attachment, Puma generates a signed, expiring 302 redirect URL to your storage provider (such as S3 or Backblaze B2). If a page renders 30 images, your Rails server spends compute generating 30 unique signed redirects on every single page load. Instead of hunting down every view to manually swap in rails_storage_proxy_path, tell Rails to route all Active Storage attachments through proxy mode by default. Add this single line to config/environments/production.rb: # config/environments/production.rb config.active_storage.resolve_model_to_route = :rails_storage_proxyNow, standard view calls automatically generate proxy endpoints: <%# Automatically resolves to /rails/active_storage/blobs/proxy/... %> <%= image_tag @article.featured_image %>When proxy mode is active, Rails streams the file directly through your domain and emits long-term, immutable caching directives on binary responses: cache-control: max-age=3155695200, public, immutableThis hands Cloudflare the exact headers needed to cache assets at the edge for months without any manual TTL overrides. Cache Active Storage at the EdgeBecause proxy paths (/rails/active_storage/blobs/proxy/* and /rails/active_storage/representations/proxy/*) serve immutable file contents, you want Cloudflare to cache them permanently so Puma never sees a repeated request. In Cloudflare, go to Caching -> Cache Rules and create an Active Storage Edge Cache rule: • Matching Expression: (http.request.method in {"GET" "HEAD"} and starts_with(http.request.uri.path, "/rails/active_storage/"))• Settings: • Cache Eligibility: Eligible for cache • Edge TTL: Use cache-control header if present, bypass cache if not (or "cache request with Cloudflare's default TTL if not") • Browser TTL: Respect origin TTL Once primed, your VPS CPU stays near 0% when pages with dozens of user-uploaded files load. Why This Works• Zero Compute on Repeat Hits: The first request pulls through Puma, Caddy, and Cloudflare to prime the cache. Every subsequent visit from anywhere in the world serves a sub-20ms edge HIT. • Permanent Browser Caching: Because Browser TTL respects origin headers, client browsers store the file locally across visits (immutable), eliminating unnecessary re-downloads. • No Direct Upload Conflicts: POST requests to direct upload endpoints bypass the rule cleanly. Verifying Edge Performance in DevToolsTo confirm Cloudflare and Hatchbox are communicating correctly: 1. Open Chrome DevTools (Cmd + Option + I or Ctrl + Shift + I) and click the Network tab. 2. Uncheck Disable cache so you can observe real-world browser caching. 3. Reload your page, click on any Active Storage image request, and open the Headers panel: • cf-cache-status: HIT: Cloudflare served the asset from its edge without touching your VPS. • cache-control: max-age=..., public, immutable: Cloudflare preserved your Rails cache headers. • via: 1.1 Caddy: Shows that Hatchbox’s native Caddy reverse proxy handled the upstream origin response when the cache was originally primed. • age: [seconds]: Shows how long the asset has been cached at the edge. 3. Asset Pipeline Edge Offloading (/assets/*)Modern Rails (whether using Propshaft, Sprockets, Vite, or Tailwind CLI) produces fingerprint-digested assets (e.g., application-d893f412.css). Caddy serves these statically from /public/assets, but Cloudflare should absorb 100% of this traffic globally: • Create a Cache Rule for Assets: • Expression: http.request.uri.path starts_with "/assets/" • Cache Eligibility: Eligible for cache • Edge TTL: Override origin -> 1 year • Browser Cache TTL: Override origin -> 1 year Because Rails digests asset filenames whenever file contents change, aggressive 1-year caching will never show stale CSS/JS after a Hatchbox deployment. 4. Protecting Dynamic Rails Routes from Cache LeaksCloudflare's default cache level respects origin Cache-Control headers, but human error in custom controllers can accidentally broadcast private sessions if you enable global caching rules. Ensure you explicitly safeguard authenticated paths by creating a Bypass Rule: • Expression: (http.request.uri.path starts_with "/admin") or (http.cookie contains "_your_app_session")• Action: Bypass cache This ensures that any user carrying a logged-in Rails session cookie always hits the origin Puma worker directly, eliminating edge-caching bugs for logged-in sessions. 5. Caddy & Rails Real IP RestorationBecause every request hits Cloudflare before reaching Hatchbox, your Rails production logs and request.remote_ip calls will show Cloudflare IP addresses by default unless restored. In your Rails configuration: # config/environments/production.rb config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [ # Trust local reverse proxy (Caddy) IPAddr.new("127.0.0.1"), IPAddr.new("::1") ]Caddy forwards the X-Forwarded-For header automatically, allowing Rails to accurately track user rate-limits, audit logs, and security geolocation. The ResultVector Unoptimized Default Hatchbox + Cloudflare Edge Stack Asset Delivery Puma/Caddy disk I/O on every cold visit Sub-20ms edge HIT globally Active Storage Puma handles 302 redirects per file load Cached at CDN; 0 Rails worker overhead Domain Redirects Full TCP/TLS trip to VPS server Edge 301 resolved before origin ping Compute Cost High memory/Puma thread exhaustion Small $6–$12 VPS comfortably scales By moving static assets, media caching, and redirect logic entirely to Cloudflare's edge, Hatchbox only has to do what Rails does best: render dynamic business logic and process background jobs.

August 26, 2026

The PaaS Hangover: Why I Deploy Rails with Hatchbox

Deploying a modern Ruby on Rails application usually forces you into one of two extremes. On one side lies the PaaS route (Heroku, Render, Fly.io). It offers simplicity: push your code, let their buildpacks figure out the rest, and go back to building features. But the convenience tax is brutal. You are locked into arbitrary memory limits, sleep cycles, and per-dyno billing that makes running three small side projects feel like leasing commercial real estate. On the other side is the DIY and containerized route (Kamal, Dokku, or raw VPS orchestration). You get cheap compute, but you also inherit the DevOps maintenance tax. You are suddenly managing Docker registries, debugging multi-stage Dockerfiles, troubleshooting proxy containers, and configuring Let’s Encrypt edge cases. For the vast majority of independent builders and lean software teams, neither extreme makes sense. That is why Hatchbox is my default deployment tool for Rails. The Power of "Bring Your Own Compute"Hatchbox is a control plane, not a hosting provider. You connect your own servers from Hetzner, DigitalOcean, Linode, or AWS via SSH, and Hatchbox handles provisioning, environment configuration, database setups, and zero-downtime deploys. This separation of concerns changes the economics completely. Take a look at some example costs here based on deployment solutions: Platform / Setup Architecture Est. Monthly Cost (3 Small Apps + DBs + Background Workers) Heroku Managed Dynos + Eco Dynos + Postgres Add-ons $70 – $150+ Render / Fly.io Managed Micro-VMs / Containers $45 – $90 Hatchbox + Hetzner/DO 1 Dedicated VPS (4GB RAM, 2 vCPU) + Hatchbox Plan $16 – $24 total With Hatchbox, you pay for raw server hardware once. If you have three low-traffic Rails applications, a handful of cron jobs, and background workers, you can pack them onto a single $6 to $12/month VPS without paying a per-application penalty. Rails-First Sensibility (No Container Friction)Most modern deployment tooling treats Rails as just another generic web service inside a Docker container. Hatchbox treats Rails as a first-class citizen because it was built specifically for it. • Boring, Understandable Infrastructure: Hatchbox provisions native Ubuntu environments with systemd services, Puma, and a fast Caddy reverse proxy. Deploys run using the proven symlink release pattern. • Frictionless Debugging: When an issue crops up, you don't need to jump through Docker namespaces or container exec commands. You SSH into your server, cd into your current release, and run bin/rails console or inspect standard Linux logs. • Modern Rails Defaults: Hatchbox natively supports Rails credentials (RAILS_MASTER_KEY), runs database migrations cleanly on release, handles asset precompilation out of the box, and works out-of-the-box with modern SQLite setups and the Solid Trifecta (Solid Queue, Solid Cache, Solid Cable). The Push-to-Deploy Experience Without the Lock-InHatchbox connects directly to GitHub. Once configured, your workflow looks identical to a high-end platform: 1. Merge your pull request to main. 2. A GitHub webhook triggers the build on your server. 3. Hatchbox runs your bundle, compiles your assets, runs pending migrations, and performs a zero-downtime phased Puma restart. 4. Caddy automatically provisions and renews your SSL certificates. You get the frictionless "push to deploy" developer experience without handing over control of your database or paying an exponential markup on RAM. The Verdict For solo developers, bootstrappers, and small teams, developer cycles are too valuable to spend debugging container orchestration, and server budgets are too lean to hand over to PaaS markup. Hatchbox occupies the exact middle ground: it automates the tedious parts of server administration while keeping your stack standard, fast, and remarkably affordable.

July 24, 2026

Zero data loss SQLite: streaming production backups to S3 with Litestream & Rails 8

In my last article, SQLite in Production is a Big Win, With a Few Tweaks, we walked through optimizing database.yml with WAL mode, proper pragmas, and connection pools to give Rails 8 blistering fast speed on a single server. When you tell traditionalists you’re running your primary database out of a flat SQLite file on a single VPS, the first objection is almost always: "What happens when the disk dies or the server catches fire?" Historically, database backups meant scheduling a nightly cron job that ran VACUUM INTO or a database dump, compressed it, and uploaded it to S3. But a nightly backup means if your server drops at 11:00 PM, you just lost 23 hours of user sign-ups, comments, and transactions. Enter Litestream. What is Litestream and why is it the gold standard?Originally created by Ben Johnson, Litestream is an open-source background tool written in Go that continuously monitors your SQLite Write-Ahead Log (-wal) file. Thanks to Stephen Margheim (@fractaledmind) and his work on the litestream-ruby gem, integrating Litestream into a Rails 8 application is now virtually plug-and-play. Every time your Rails application writes data to the log, Litestream asynchronously streams those delta frames off to S3-compatible cloud storage (like AWS S3, Backblaze B2, Cloudflare R2, or Tigris). Why it's a game-changer for Rails monoliths:• Near-Zero Recovery Point Objective (RPO): Your off-site backups are updated sub-seconds or seconds after a write occurs. • Zero Request Overhead: It doesn't lock tables or block Puma threads. It reads the WAL file completely outside your Rails request cycle. • Point-In-Time Recovery (PITR): You can restore your database to the exact second before a bad migration ran or an admin accidentally deleted data. Step 1: Install the litestream GemThe cleanest way to add Litestream to a Rails 8 app is via the litestream gem, which packages the binary and hooks directly into your application boot process. Add it to your Gemfile: gem "litestream"Then bundle and run the installer: bundle install bin/rails generate litestream:installStep 2: Configure config/litestream.yml (The Gotchas)The generator creates a config/litestream.yml file. Below is an optimized production setup targeting Backblaze B2, though Litestream works seamlessly across all S3 storage vendors. (If you're using AWS S3, Cloudflare R2, Tigris, or DigitalOcean Spaces, check out the official Litestream Provider Guides for provider-specific endpoint quirks.) # config/litestream.yml access_key_id: $LITESTREAM_ACCESS_KEY_ID secret_access_key: $LITESTREAM_SECRET_ACCESS_KEY dbs: # Path to your primary shared production database - path: /home/deploy/your_app/shared/storage/production.sqlite3 replicas: - type: s3 bucket: $LITESTREAM_REPLICA_BUCKET path: production.sqlite3 endpoint: $LITESTREAM_ENDPOINT_URL force-path-style: true part-size: 10485760 # 10MB chunk buffer (Required for Backblaze B2 / non-AWS S3) sync-interval: 10s # Batches writes to keep S3 API calls ultra-lowWhy We Custom-Tuned part-size and sync-intervalYou’ll notice two specific overrides in the configuration above that differ from Litestream's default values: 1. part-size: 10485760 (10MB): Litestream uses S3 multipart uploads to ship WAL chunks. Backblaze B2 strictly enforces a 5MB minimum part size limit on multipart API calls. Setting part-size explicitly to 10MB (‭$10 \times 1024 \times 1024$‬‭‬‭‬ bytes) provides a comfortable buffer above B2's lower threshold to prevent rejected uploads, while keeping VPS memory consumption light. 2. sync-interval: 10s: By default, Litestream checks for database changes every second. On low-to-medium traffic sites (like a blog or SaaS dashboard), setting a 10s interval batches minor write bursts into a single S3 request. This caps your potential data loss window (RPO) to a maximum of 10 seconds while keeping your cloud storage API transaction bill near zero. The Golden Rule: only back up primary dataRails 8 uses SQLite backends for solid_cache, solid_queue, and solid_cable by default. Do NOT list those databases in Litestream! Cache data churns constantly, and queue rows exist for milliseconds. Replicating them will flood your S3 bucket with millions of pointless write operations and inflate your cloud storage bill. Cache and queue databases auto-initialize on boot if wiped—only stream production.sqlite3. Step 3: boot Litestream via PumaBecause Litestream is a long-running process, you can spawn it right alongside Puma using the built-in plugin. Add this to config/puma.rb: # Spawns Litestream alongside Puma in production plugin :litestream if ENV.fetch("RAILS_ENV", "production") == "production"Once you set your S3 credentials in your server environment variables and deploy, Puma boots Litestream automatically on server start. Disaster recovery: what happens when the server dies?Here is the magic. Suppose your VPS provider has a hardware failure and your entire server disappears into the void. 1. Provision a fresh server (or let your deployment tool like Hatchbox do it). 2. Deploy your application code. 3. Before booting Puma, trigger Litestream’s restore command: bin/rails litestream:restoreLitestream inspects your config/litestream.yml, connects to your S3 bucket, downloads the latest full generation snapshot and WAL deltas, and reassembles your production database down to the last 10 seconds of activity. You boot Puma, and your app is back online with zero data loss. ConclusionRunning SQLite in production on Rails 8 gives you incredible simplicity, sub-millisecond query performance, and dirt-cheap hosting. Pairing it with Litestream solves the final piece of the puzzle: rock-solid, automated disaster recovery.

July 04, 2026

SQLite in production is a big win, with a few tweaks

Rails is fantastic if you want to truly build the ultimate monolith and with the advent of Rails 8, it's gotten even better. This is largely because of great folks in the Rails community like Stephen Margheim and the fine folks at 37signals who really fine-tuned Rails to not only take advantage of power and speed of SQLite, but solved the biggest challenge of concurrency. Now, we can have a screaming fast setup, all powered by onboard SQLite - meaning no dedicated database server, no latency in waiting for data to make the round trip, and money savings realized as an added bonus. I am currently running SQLite in production on several apps including this one, which utilizes SQLite for the main database and then Rails' Solid Trifecta - Solid Cache, Solid Queue and Solid Cable. By using Solid Queue, for example, you can get rid of the need for external services like Redis and Sidekiq. While SQLite will work out of the box in production, you do want to make some tweaks for optimization and edge cases that could cause slowdowns or write locks. Here's a look at an optimized SQLite setup in Rails 8 for production and the explanation behind it. In the database.yml, just a few lines makes a big difference. default: &default adapter: sqlite3 pool: 64 # Changed from max_connections to standard 'pool' and bumped up timeout: 5000 # This sets the busy_timeout at the connection level pragmas: journal_mode: wal synchronous: normal mmap_size: 134217728 # 128 MB memory-mapped I/O for faster reading cache_size: -64000 # 64MB cache size journal_size_limit: 67108864 # 64 MB WAL journal file limit foreign_keys: true # Enforce foreign key constraints temp_store: memory # Store temp tables/indexes in memory development: <<: *default database: storage/development.sqlite3 test: <<: *default database: storage/test.sqlite3 production: primary: <<: *default database: storage/production.sqlite3 cache: <<: *default database: storage/production_cache.sqlite3 migrations_paths: db/cache_migrate queue: <<: *default database: storage/production_queue.sqlite3 migrations_paths: db/queue_migrate cable: <<: *default database: storage/production_cable.sqlite3 migrations_paths: db/cable_migrateSQLite's defaults are optimized for single-user embedded devices (like mobile phones). These changes specifically configure it to act like a enterprise-grade server database. The difference here from the default is the added pragmas which give the server explicit instructions and allow both Puma's web threads and Solid Queue's background workers to handle the workload gracefully. Here's a breakdown of what the pragmas are doing. journal_mode: WAL (Write-Ahead Logging) • What it means: By default, SQLite locks the entire database when writing. WAL mode completely changes this by writing new data to a separate "roll-forward" journal file instead of the main database file first. • Why it matters: This is the secret sauce for concurrency. It allows multiple readers to read the database at the exact same time a writer is writing to it. They don't block each other anymore. synchronous: NORMAL • What it means: This controls how aggressively SQLite forces data to be synced to the physical disk. In FULL mode, SQLite pauses and waits for the disk to confirm data is written at every critical step. In NORMAL mode, it syncs less frequently, mostly at critical checkpoints in WAL mode. • Why it matters: It is a massive speed boost for writes. If your server suddenly loses power, there is a tiny chance the very last transaction could be lost, but the database will not become corrupted. For 99% of web apps, this trade-off is absolutely worth it. mmap_size: 134217728 (128 MB) • What it means: This tells the operating system to map up to 128 MB of the database file directly into the application's memory space (Memory-Mapped I/O). • Why it matters: Instead of the OS constantly making slow system calls to read chunks of the database file from the disk, it reads directly from RAM. It makes read operations incredibly snappy. cache_size: -64000 (64 MB) • What it means: This sets the maximum number of database pages SQLite will hold in memory. The negative number is a clever SQLite trick: it means "measure in kilobytes" rather than page count. So, -64000 allocates exactly 64 MB of RAM for the cache. • Why it matters: It ensures frequently accessed data (like your application's hot rows and indexes) stays in RAM, reducing the need to look at the disk at all. journal_size_limit: 67108864 (64 MB) • What it means: This puts a cap on how large that WAL journal file can grow before SQLite automatically shrinks it back down. • Why it matters: Without this, if you have a massive burst of writes, your WAL file could grow to gigabytes, eating up disk space and making the "checkpointing" process (merging the WAL back into the main database) sluggish. foreign_keys: true • What it means: This turns on standard relational database guardrails. If you try to delete a user who still has active orders, SQLite will stop you and throw an error. • Why it matters: Historically, SQLite left this off by default for backwards compatibility. Turning it on ensures your data stays clean and relationships don't break. timeout: 5000 • What it means: "If the database is currently busy writing something else, don't panic and crash. Wait up to 5 seconds to see if it clears up before throwing an error." • Why it matters: SQLite allows unlimited simultaneous readers, but only one writer at a time. If User A is saving a long blog post, the database is briefly locked for writing. If User B clicks "Buy Now" at that exact microsecond, SQLite would normally instantly fail and show User B a nasty database is locked error. With timeout: 5000 enabled, User B’s request simply pauses for a few milliseconds, waits for User A to finish, and then executes seamlessly. Since SQLite writes take microseconds, User B won't even notice the pause. temp_store: memory • What it means: "When you need to build temporary tables or sort massive lists behind the scenes, do it directly in RAM instead of creating temporary files on the hard drive." • Why it matters: Whenever Rails executes a complex query that involves heavy sorting (.order), grouping (.group), or complex JOIN statements, SQLite often needs to create a temporary "scratchpad" index to figure out the answer. By default, it writes that scratchpad to the server's hard drive. Forcing it into memory means SQLite uses lightning-fast RAM for its math homework, resulting in massive speed boosts for your heaviest ActiveRecord queries and eliminating unnecessary disk wear and tear. You might have noticed in the database.yml above that we set pool: 64 instead of matching it dynamically to Puma's RAILS_MAX_THREADS. Because Rails 8 splits our primary application data, Solid Queue, and Solid Cache into their own independent SQLite files, each process gets its own connection pool. Since SQLite connections are handled purely in-memory with virtually zero overhead, setting a high static pool ensures that when Solid Queue spins up its concurrent background workers, they will never be starved for database connections. Running the "Solid Trifecta" on a single bare-metal server or VPS gives you an app that feels incredibly snappy, costs pennies to host, and completely cuts out the complexity of external dependencies like Redis. But you might be wondering: “If my whole database is just a flat file on a single server, what happens if that server goes down?” In a traditional setup, a compromised server means lost data. But with modern SQLite tools, it doesn't have to. In my next article, I’ll show you how to pair this exact configuration with Litestream to stream real-time, per-second cryptographic backups directly to an S3 bucket or Object Storage for ultimate peace of mind. Until then, drop your database optimizations in the comments below!