Serving Static Files with Reverse Proxy using X-Sendfile

Serving Large Files in Rails with a Reverse Proxy Server

If your Rails app deals with large files, let a reverse proxy like Nginx or Thruster serve them. In this post, we'll learn how X-Accel-Redirect (or X-Sendfile) header hands off file delivery to Nginx. We'll also read Thruster’s source code to learn how this pattern is implemented at the proxy level.

12 min read

Video: Serving Large Files with a Reverse Proxy Server

TL;DR: If you don't want to read the >3000 word post, here's the gist:

X-Sendfile (or a variation of it, such as X-Accel-Redirect) is an HTTP response header used by web servers (like Nginx, Apache, or Thruster) to efficiently serve files to clients without the Rails application server having to load file in-memory and serve it.

How does it work?

Suppose you need to serve a large, sensitive, access-controlled file (i.e., a 650 MB set of architectural drawings) that requires authentication and a permissions check. Let's also assume the app server is sitting behind a reverse proxy like Nginx, (or Thruster, if you're using Kamal).

Note: This is different from serving public files such as stylesheets and images from a public CDN as your Rails app still needs to authenticate and verify the user before serving these access-controlled files.

Instead of having your Rails app read that file in memory and send it byte-by-byte to the user (which is slow and memory-intensive), the Rails app only handles authentication and authorization, and then delegates the file serving to the web server by responding with:

HTTP/1.1 200 OK
X-Sendfile: /path/to/file.pdf

And the web server, which is optimized for file I/O, takes over and streams /path/to/file.pdf directly to the client.

Benefits

  • Performance: Offloads file delivery to the web server.
  • Memory Efficiency: App doesn’t need to load the file into memory.
  • Security: App controls which files can be served — web server just serves what it's told.

When to use it?

  • You're serving large files (e.g., PDFs, videos, ZIPs).
  • You need authentication/authorization before sending the file.
  • You're hitting performance/memory bottlenecks when loading and serving files.

When is it not needed?

  • Extremely Small Files: For very small files (just a few KBs), using X-Sendfile isn’t really necessary. Serving them directly works just as well and avoids the extra step.
  • Dynamically Generated Content: If the file content needs to be generated or modified on-the-fly for each request after authentication (e.g., watermarking an image with user-specific data just before sending), you'll need to stream it from the application itself. X-Sendfile is for pre-existing files on disk.
  • Development Environment: Rails serves files directly using its own simpler (and slower) mechanism, which is fine for development ease. You wouldn't usually have Nginx/Apache fully configured in front of your local Rails dev server (or do we? but that's a topic for another post).

And that’s the short version. Let’s dive a little deeper…


I recently started working on a new client project, a self-hosted file storage and sharing platform for businesses, written in Ruby on Rails. Think ONCE-styled self-hosted software, but for enterprises. It's been around for over a decade and half, and is quite established and successful in the industry.

Anyway, one of the first interesting things I learned in my first week was how the application serves large files using Nginx as a reverse proxy, instead of having Rails handle the file streaming. This setup avoids loading large files into memory inside the Rails app server.

In this post, I’ll share what I’ve learned about serving large files in Rails using reverse proxies, including:

  • What the X-Sendfile pattern is
  • How to use it in a Rails application
  • How proxies like Thruster implement it by reading its source code

Let's begin with the problem.

Application Servers Aren’t Designed for Heavy File I/O

Imagine your Ruby on Rails application needs to serve a 450 MB drawing that's generated for a specific user. Here's what happens if Rails handles it directly:

  1. Request Hits Rails: The user clicks a download link, and the request hits your Rails controller.
  2. Authentication/Authorization: Rails performs necessary checks to ensure the user is allowed to access the file.
  3. File Read: Rails opens the file from the disk.
  4. Memory Allocation: Rails reads the file content (potentially the entire 450MB) into its own memory space.
  5. Streaming Data: Rails then sends this data chunk by chunk over the network to the user's browser.

This approach works, but it has several limitations that become more apparent under load.

First, the Ruby process handling the request is tied up for the entire duration of the file transfer. While the file is being streamed to the user, that process can't handle other application requests. This directly affects concurrency and reduces the overall throughput of your application.

Second, reading large files into memory can lead to high memory usage. Depending on how your app handles streaming, it may load the entire file into RAM before sending it out. This increases the risk of memory bloat, swapping, or even out-of-memory errors, particularly on smaller machines or during periods of high traffic.

Third, the data flow itself is inefficient. The file is read from disk into user space (your Rails process), and then written back out to the network socket; another transition back into kernel space. This roundtrip results in multiple context switches and unnecessary memory copying, which add overhead and consume CPU cycles.

As the number of simultaneous downloads increases, these inefficiencies add up. With multiple users downloading large files at the same time, your application servers can quickly become overloaded. The result is slower response times, reduced availability for non-file-serving requests, and a degraded experience for all users.

The main issue is that application servers like Puma are optimized for handling business logic, database queries, and rendering views. They're not built to perform high-throughput, low-level I/O operations like bulk file transfers. That responsibility is better handled by something lower in the stack, like the web server or a reverse proxy.

Offload File Serving to a Reverse Proxy

A better approach in above cases is to let the application handle the logic (like authentication) and then tell a reverse proxy server (which sits in front of your Rails app) to serve the file directly.

Serving Static Files with Reverse Proxy using X-Sendfile
Serving Static Files with Reverse Proxy using X-Sendfile
💡
What is a Reverse Proxy? A reverse proxy (e.g., Nginx, Apache) is a server that sits in front of web servers and forwards client (e.g., browser) requests to those web servers. They are optimized for tasks like SSL termination, load balancing, caching, and serving static content. If you want to understand reverse proxies in detail, this article from Cloudflare does a great job.

How It Works

  1. Request Hits Application: The request comes to your application (e.g., Rails, Go service).
  2. Authentication/Authorization: The application validates the request.
  3. Signal for Offload via Header: Instead of reading and sending the file, the application sends a special HTTP header in its response. The most common one discussed is X-Sendfile. The value of this header is typically the absolute path to the file on the server's filesystem.
    • Example: X-Sendfile: /var/www/my_app/shared/private_files/report.pdf
    • Nginx uses a similar concept with the X-Accel-Redirect header, which points to an internal URI. The response body sent by the application is typically empty when using this mechanism.
    • The static files do not need to be inside the Rails project folder. In fact, in most setups, they're stored outside the Rails app directory. As long as the reverse proxy (e.g. Nginx) has access to the file path and proper permissions to read it.
  4. Proxy/Handler Interception: The reverse proxy (e.g., Nginx) or an internal middleware (like Thruster's SendfileHandler) is configured to watch for this specific header.
  5. Proxy/Handler Serves the File: Upon seeing the header, the proxy/handler discards the (likely empty) response body from the application. It then reads the file specified in the X-Sendfile (or equivalent) header directly from the disk and streams it to the client.
  6. Application Process Frees Up: The application process is released almost immediately after sending the headers, allowing it to handle new incoming requests.

Benefits of Serving Static Files with a Reverse Proxy

Proxy servers are highly optimized for I/O operations. They can often use advanced kernel features (like the sendfile system call, discussed later) to transfer files much faster.

Your Rails (or other application) servers are no longer bogged down with file I/O, reducing the load on the app servers. They can focus on their primary role: executing application logic. This means they can handle significantly more concurrent users and dynamic requests.

The application doesn't need to load large files into its memory. This lowers memory footprint, saves RAM and improves overall system stability. Also, by offloading file transfers, the application can serve a higher number of simultaneous requests.

💡
By default, NGINX handles file transmission itself and copies the file into the buffer before sending it. Enabling the sendfile directive eliminates the step of copying the data into the buffer and enables direct copying data from one file descriptor to another.

Implementing SendFile in Rails

In Rails, much of this "middleware" logic is built into Rack::SendFile, which is configured via config.action_dispatch.x_sendfile_header. When you use send_file in a controller and this config is set, this middleware:

  1. Sets the X-Sendfile (or X-Accel-Redirect, etc.) header.
  2. Sets the file path
  3. Sends an empty response body.
  4. Relies on the upstream proxy (Nginx/Apache) to be configured to act on this header.

First, you set the config.action_dispatch.x_sendfile_header value depending on your proxy header:

    • For Nginx: "X-Accel-Redirect"
    • For Apache and Thruster: "X-Sendfile"
Rails.application.configure do
  # ... other configurations ...

  # Tell Rails to use Nginx's X-Accel-Redirect header for file serving.
  # Ensure Nginx is configured to handle this header and the internal location.
  config.action_dispatch.x_sendfile_header = "X-Accel-Redirect"

  # Or for Apache:
  # config.action_dispatch.x_sendfile_header = "X-Sendfile"
  # Ensure Apache's mod_xsendfile is enabled and configured.
end

Next, use send_file in your controllers (or just set the above header manually):

class FilesController < ApplicationController
  def download_report
    # Ensure user is authenticated and authorized
    authenticate_user!
    authorize! :download, @report

    report_path = Rails.root.join("private_data", "reports", "user_#{current_user.id}_report.pdf").to_s

    if File.exist?(report_path)
      # When x_sendfile_header is configured, send_file will:
      # 1. Set the X-Accel-Redirect (or X-Sendfile) header with the path.
      # 2. Set Content-Type, Content-Disposition.
      # 3. Return an empty body.
      send_file report_path,
                filename: "financial_report.pdf",
                type: "application/pdf",
                disposition: "attachment" # 'inline' or 'attachment'
    else
      head :not_found
    end
  end
end

With this setup, when download_report is called in production, Rails will send the appropriate headers, and Nginx (or Apache) will handle the actual file streaming. The Rails process will be freed very quickly.

The sendfile(2) System Call: The Magic Behind the Scenes

One of the reasons proxy servers are so efficient at serving files is their ability to use the sendfile(2) system call, which allows data to be transferred directly from one file descriptor (e.g., a file on disk) to another (e.g., a network socket) within the kernel space.

💡
sendfile() copies data between one file descriptor and another. Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

Without sendfile(2) (Traditional Method):

  1. Read data from disk into a kernel buffer.
  2. Copy data from the kernel buffer to a user-space buffer (e.g., in Nginx's memory).
  3. Copy data from the user-space buffer back to a kernel buffer (associated with the socket).
  4. Send data from the socket kernel buffer over the network.

With sendfile(2) (Zero-Copy or Near Zero-Copy):

  1. sendfile(2) tells the kernel to send data from the input file descriptor directly to the output socket descriptor.
  2. Data is copied directly from the disk cache to the socket buffer, bypassing user space entirely.

This reduces CPU usage and memory bandwidth by eliminating redundant data copies and context switches.

For example, when using Nginx, your Rails application would send an X-Accel-Redirect header. The value of this header is not a direct filesystem path but an internal URI that Nginx is configured to handle. This header specifies that a given location can only be used for internal requests. For external requests, the client error 404 (Not Found) is returned.

Rails Controller Example:

# In your Rails controller
class DownloadsController < ApplicationController
  before_action :authenticate_user, only: [:show]
  def show
    # Assume file_path_on_disk is something like "/var/www/app/shared/secure_files/data.zip"
    # And you've configured Nginx to map "/protected_files/" to this physical path.
    internal_redirect_uri = "/protected_files/data.zip" # This is a URI, not a file path
    response.headers["X-Accel-Redirect"] = internal_redirect_uri
    response.headers["Content-Type"] = "application/zip" # Set appropriate content type
    response.headers["Content-Disposition"] = "attachment; filename="data.zip"" # Suggest a filename
    head :ok # Send an empty body with a 200 OK status
  end
end

How it works:

  1. Rails sends X-Accel-Redirect: /protected_files/data.zip.
  2. Nginx intercepts this header.
  3. It internally redirects the request to the /protected_files/ location.
  4. The internal directive ensures this location cannot be accessed directly from the outside.
  5. The alias directive tells Nginx that /protected_files/data.zip should be served from /var/www/app/shared/secure_files/data.zip.
  6. Nginx serves the file, potentially using sendfile(2).

That's it.

Bonus: How Thruster Serves Files with X-Sendfile

Since, I’ve been working on a product where uploading and serving big files is a key feature, it was the perfect excuse to dig into this pattern and figure out how it actually works. Now, reading Nginx source code was quite out of reach for me, since it's written in C, and I am not yet comfortable reading large swaths of source code of a large C project.

But then I remembered about Thruster. I have a few Rails apps that are deployed using Kamal, which uses Thruster — a lightweight HTTP/2 proxy designed for deploying Rails apps.

If you haven't come across it, here’s what Thruster offers:

  • HTTP/2 support
  • Automatic TLS with Let’s Encrypt
  • Basic HTTP caching of public assets
  • X-Sendfile support and file compression, to efficiently serve static files

The nice thing about Thruster is that it’s written in Go, a language I enjoy and find incredibly readable. In fact, Go is one of my most favorite languages for reading code.

💡
If you’ve never read the Go standard library source code, I highly recommend it. It’s very well written and can teach you a lot about good engineering. It will make you a better programmer, trust me.

Anyway, so I cloned the Thruster repo and was pleasantly surprised to see the codebase is quite small, and very readable. I spent the last weekend just digging into it, and have learned a ton about HTTP proxies. This was so much fun, and I plan to read the source code of the Kamal Proxy next, but that's for a future post.

Thruster implements its own SendfileHandler middleware. Unlike the Nginx/Apache examples where file serving is offloaded to a separate proxy server, Thruster's wraps the Puma process so that you can use it without managing multiple processes yourself.

What follows is a simple breakdown of Thruster's sendfile handler. The logic for this lives in the SendfileHandler. Let’s walk through it step by step. I suggest you open the handler code in a new tab or even better, clone the Thruster repo and walk through with me.


The Handler Setup

The SendfileHandler struct wraps an http.Handler, allowing it to sit in the HTTP middleware chain. When initialized with enabled: true, it enables X-Sendfile processing for all downstream responses.

type SendfileHandler struct {
	enabled bool
	next    http.Handler
}

func NewSendfileHandler(enabled bool, next http.Handler) *SendfileHandler {
	return &SendfileHandler{
		enabled: enabled,
		next:    next,
	}
}

This is the entry point for all requests passing through this middleware. If the handler is enabled, it:

  1. Adds an X-Sendfile-Type header to the request. This is to tell the upstream server (Rails) the header to use, in case it's set dynamically.
  2. Wraps the http.ResponseWriter with a custom implementation called sendfileWriter.
func (h *SendfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if h.enabled {
		r.Header.Set("X-Sendfile-Type", "X-Sendfile")
		w = &sendfileWriter{w, r, false, false}
	} else {
		r.Header.Del("X-Sendfile-Type")
	}

	h.next.ServeHTTP(w, r)
}

The idea is to transparently observe and act upon responses that include an X-Sendfile header, without changing the behavior of responses that don’t.

Detecting File Location

The sendfileWriter struct wraps the standard http.ResponseWriter. It overrides Write and WriteHeader to intercept the response lifecycle.

When a response is being written, WriteHeader checks if the response includes an X-Sendfile header:

func (w *sendfileWriter) sendingFilename() string {
	return w.w.Header().Get("X-Sendfile")
}

If that header is present, it means Rails has instructed the proxy to serve a file. At this point, the proxy:

  • Deletes the X-Sendfile header to avoid exposing internal paths to the client.
  • Calls http.ServeFile(...) to stream the file directly to the client.
func (w *sendfileWriter) WriteHeader(statusCode int) {
	filename := w.sendingFilename()
	w.w.Header().Del("X-Sendfile")

	w.sendingFile = filename != ""
	w.headerWritten = true

	if w.sendingFile {
		w.serveFile(filename)
	} else {
		w.w.WriteHeader(statusCode)
	}
}

func (w *sendfileWriter) serveFile(filename string) {
	slog.Debug("X-Sendfile sending file", "path", filename)

	w.setContentLength(filename)
	http.ServeFile(w.w, w.r, filename)
}

This bypasses the usual response body Rails might have written and replaces it with a direct file stream.

What This Means for Rails Developers

From the Rails side, all you need to do is include the appropriate header in your controller:

response.headers['X-Sendfile'] = file_path
head :ok

The reverse proxy takes over from there. No need to load the file into memory or stream it through the Rails process. Thruster detects the header, locates the file on disk, and handles the delivery efficiently.


That's a wrap. I hope you found this article helpful and you learned something new. The main lesson is that when it's needed, you can delegate the actual file transmission to a specialized proxy server like Nginx or Thruster to free up your application server (e.g., Puma in Rails) to do what it does best: handle application logic.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.