How to Set Up Nginx as a Reverse Proxy for Memcached

How to Set Up Nginx as a Reverse Proxy for Memcached

When I first started tuning a high-traffic PHP application a few years back, the database was the obvious bottleneck. The queries themselves were fine, but the sheer volume of repeated reads was killing response times. Memcached solved the caching problem at the application layer, but I quickly realized there was an even faster path: letting Nginx talk to Memcached directly, bypassing the application entirely for cached responses. That’s what this guide is about.

Nginx ships with a built-in module called ngx_http_memcached_module that lets Nginx act as a reverse proxy in front of Memcached. Instead of your PHP, Python, or Node application handling every single request — even ones that are just fetching a cached fragment — Nginx can query Memcached directly and serve the response, skipping your application server entirely. The performance difference is significant, especially under heavy load.

In this article, I’ll walk you through exactly how this works, why you’d want it, and how to configure it correctly, including the parts that trip people up.

Why Bother Proxying Straight to Memcached?

Let me explain the architecture problem this solves. In a typical caching setup, a request comes in, hits your web server, gets passed to your application, and your application checks Memcached, gets a hit, and returns the cached value. That’s still several hops: client → Nginx → app server → Memcached → app server → Nginx → client.

With the memcached module, you can shortcut this to: client → Nginx → Memcached → client. No PHP-FPM worker gets spun up, no Python WSGI process gets touched, no Node event loop gets blocked. For read-heavy endpoints — think product pages, article content, API responses that don’t change every second — this can dramatically reduce load on your application tier and cut latency.

I want to be upfront about a limitation here, though: Nginx’s memcached module is read-only. It cannot write to Memcached. Your application still needs to populate Memcached with the data, and it needs to do so using a key-naming scheme that Nginx can predict from the incoming request URI. Nginx just fetches; it doesn’t set.

Requirements Before You Start

Before diving into configuration, make sure you have the following in place:

  • A Linux server (Ubuntu, Debian, CentOS, or similar) with root or sudo access
  • Nginx installed with the ngx_http_memcached_module compiled in. Good news: this module is compiled in by default in most prebuilt Nginx packages, including the ones from the official Nginx repositories and most distro package managers. You can confirm with nginx -V 2>&1 | grep -o with-http_memcached_module.
  • Memcached installed and running on the same server or reachable over the network
  • An application layer capable of writing to Memcached using predictable keys (this is the part people often overlook)

If you don’t have Memcached installed yet, here’s how to get it running on Ubuntu/Debian:

sudo apt update
sudo apt install memcached libmemcached-tools -y

On CentOS/RHEL/Rocky Linux:

sudo dnf install memcached -y

Start and enable the service:

sudo systemctl enable --now memcached

By default, Memcached listens on 127.0.0.1:11211. You can verify it’s running with:

sudo systemctl status memcached

or by connecting directly:

echo -e "version\r\nquit\r" | nc 127.0.0.1 11211

If you get a version string back, Memcached is alive and listening.

Understanding How the Memcached Module Works

The core directive here is memcached_pass, which tells Nginx where to send the lookup request. Nginx builds a Memcached key based on the $memcached_key variable, which you set yourself, typically derived from $uri or $request_uri.

Here’s the critical part: whatever key your application uses to set a value into Memcached must exactly match the key Nginx constructs when it does the get. If these don’t line up — and I’ve seen this go wrong more times than I can count — you’ll get nothing but cache misses.

Nginx also needs a fallback for when a key isn’t found in Memcached, since not every request will have a cached counterpart. This is handled through the error_page directive combined with a named location that proxies to your actual application server.

Step-by-Step Configuration

Step 1: Confirm the Module Is Available

Run:

nginx -V

Look through the output for --with-http_memcached_module. If it’s there, you’re set. If you compiled Nginx from source without it, you’ll need to recompile with ./configure --with-http_memcached_module added to your build flags.

Step 2: Populate Memcached From Your Application

This is a step people skip in tutorials, but it’s the foundation of the whole thing. Your application needs to store values in Memcached using a key that corresponds to the request URI. For example, in PHP:

$memcached = new Memcached();
$memcached->addServer('127.0.0.1', 11211);
$key = $_SERVER['REQUEST_URI'];
$memcached->set($key, $renderedHtml, 300); // cache for 5 minutes

In Python, using pymemcache:

from pymemcache.client import base

client = base.Client(('127.0.0.1', 11211))
key = request.path
client.set(key, rendered_html, expire=300)

The exact key format matters a lot, and I’ll show you how to match it in the Nginx config next.

Step 3: Write the Nginx Server Block

Here’s a complete, working example configuration:

upstream memcached_backend {
    server 127.0.0.1:11211;
}

upstream app_backend {
    server 127.0.0.1:8080;
}

server {
    listen 80;
    server_name example.com;

    location / {
        set $memcached_key $uri;
        memcached_pass memcached_backend;

        default_type text/html;
        error_page 404 405 502 = @fallback;

        add_header X-Cache-Status HIT always;
    }

    location @fallback {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        add_header X-Cache-Status MISS always;
    }
}

Let’s break down what’s happening:

  • $memcached_key $uri; — this sets the lookup key to the request URI, e.g., /products/42. This must match whatever key your application used when writing to Memcached.
  • memcached_pass memcached_backend; — this tells Nginx to attempt the Memcached lookup using the upstream block defined above.
  • error_page 404 405 502 = @fallback; — if the key isn’t found (Memcached returns a “not found” that Nginx translates into a 404-like internal response), or if there’s any connection issue, Nginx falls back to the named location.
  • @fallback — this is a standard proxy_pass block that forwards the request to your actual application server, which will presumably regenerate the content and possibly repopulate the cache.

Step 4: Test the Configuration

Always test before reloading:

sudo nginx -t

If the syntax is valid, reload Nginx:

sudo systemctl reload nginx

Step 5: Populate a Test Key and Verify

Manually set a key using the memcached-tool or a raw telnet-style session:

printf "set /test-page 0 300 13\r\nHello, World!\r\n" | nc 127.0.0.1 11211

This sets a key called /test-page with a 300-second expiration and a 13-byte value (“Hello, World!”).

Now request it through Nginx:

curl -i http://example.com/test-page

You should see “Hello, World!” returned directly, along with your X-Cache-Status: HIT header. If you request a URI that isn’t cached, you should see the fallback location engage and the X-Cache-Status: MISS header appear instead.

Handling Binary and Non-HTML Content

By default, memcached_pass treats responses as generic data, and you control the Content-Type via default_type. If you’re caching JSON API responses, adjust accordingly:

location /api/ {
    set $memcached_key "api:$uri";
    memcached_pass memcached_backend;
    default_type application/json;
    error_page 404 502 = @api_fallback;
}

Notice I’ve prefixed the key with api: here — this is a common convention to namespace your cache keys so you don’t accidentally collide with keys from a different part of your application using the same URI structure.

Including Query Strings in the Cache Key

If your pages vary by query string (pagination, filters, sort order), you cannot rely on $uri alone since it excludes query parameters. Use $request_uri instead:

set $memcached_key $request_uri;

Just be aware this means your application needs to normalize and sort query parameters consistently, or you’ll end up with cache fragmentation — the same logical page cached under many slightly different keys like ?page=2&sort=asc versus ?sort=asc&page=2.

Troubleshooting Common Issues

Problem: Every request results in a cache miss, even right after setting the key manually.

This is almost always a key mismatch. Double-check whether your application is writing keys with a leading slash, trailing slash, hostname prefix, or hashed value that doesn’t match $uri or $request_uri. I recommend logging the exact key your app uses and comparing it byte-for-byte with what Nginx would generate.

Problem: Nginx returns a 502 error instead of falling back gracefully.

Make sure your error_page directive includes 502 in the list of codes that trigger the fallback location, since a connection failure to Memcached (not just a “key not found”) can surface as a 502.

Problem: Binary or compressed values come back garbled.

If your application stores gzip-compressed or serialized binary data in Memcached, make sure Nginx isn’t trying to re-encode or interpret it. Keep default_type set appropriately and avoid applying gzip on; to a location that’s already serving pre-compressed content, or you’ll double-compress it.

Problem: Memcached connection refused.

Verify Memcached is actually listening where you expect:

sudo ss -tlnp | grep 11211

If it’s bound to 127.0.0.1 but your upstream block points to a different IP, or vice versa, fix the mismatch in either /etc/memcached.conf or your Nginx upstream definition.

Security Considerations

Memcached was never designed with authentication or encryption in mind, and this matters a lot here. A few practical rules I follow:

  1. Never expose Memcached’s port (11211) to the public internet. Bind it to 127.0.0.1 or a private internal network interface only. Check /etc/memcached.conf for the -l (listen address) setting.
  2. Firewall it explicitly, even if it’s bound to localhost, as a defense-in-depth measure: sudo ufw deny 11211
  3. Don’t cache sensitive or user-specific data through this mechanism unless you’ve built per-user key isolation and you’re confident about cache key collisions. This proxy path bypasses your application’s usual authentication and authorization logic entirely — Nginx doesn’t know or care who’s asking, it just returns whatever’s under that key. This is the single biggest security mistake I see: someone caches a personalized dashboard by URI, and now any user who guesses or is given that URI sees someone else’s data.
  4. Set reasonable expirations. Memcached is not a database; treat every value as ephemeral, and don’t let stale, sensitive data linger.
  5. Isolate multi-tenant deployments by prefixing cache keys with a tenant or account identifier and never trusting client-supplied identifiers directly in the key without validation.

Performance Tips

  • Keep Memcached’s -m (memory limit) sized appropriately for your working set. Too small, and you’ll get frequent evictions that defeat the purpose. Check current stats with memcached-tool 127.0.0.1:11211 stats.
  • Co-locate Memcached on the same host as Nginx when possible to avoid network latency, or use a fast private network link if you’re running a dedicated cache tier.
  • Monitor your hit ratio. A low hit ratio (below roughly 80% for content that should be highly cacheable) usually points to a key mismatch or overly short expiration times, not a Memcached problem itself.
  • Consider memcached_connect_timeout, memcached_send_timeout, and memcached_read_timeout directives to avoid slow Memcached connections stalling your Nginx workers: memcached_connect_timeout 200ms;memcached_send_timeout 200ms;memcached_read_timeout 200ms;
  • Use keepalive connections between Nginx and Memcached in high-throughput environments by defining the upstream with a keepalive directive: upstream memcached_backend { server 127.0.0.1:11211; keepalive 32;}

Real-World Use Cases

I’ve used this pattern most successfully in a few specific scenarios:

  • Static-ish content pages: blog posts, product descriptions, category pages that change infrequently but get hammered by traffic.
  • API response caching: read-heavy GET endpoints where the underlying data changes on a predictable schedule (e.g., every few minutes via a cron job that repopulates Memcached).
  • Fragment caching for rendered HTML: rather than caching the whole page, some teams cache rendered partials and assemble them with SSI or edge includes, though this adds complexity.
  • Reducing load during traffic spikes: during flash sales or sudden traffic surges from social media, having Nginx serve straight from Memcached kept application servers from falling over while the cache absorbed the brunt of the read traffic.

Best Practices Summary

  • Always define a fallback location for cache misses; never let a missing key result in a hard error for the user.
  • Namespace your keys clearly and document the key format your application uses so future engineers (including future you) don’t break the mapping.
  • Restrict Memcached network access aggressively.
  • Set explicit timeouts so a slow or down Memcached instance doesn’t cascade into slow page loads.
  • Monitor hit/miss ratios and adjust TTLs based on actual traffic patterns, not guesses.
  • Don’t use this pattern for anything requiring authentication-aware responses unless you’ve deliberately engineered key isolation per user or session.

Monitoring Cache Health Over Time

Once this is running in production, I don’t just set it and forget it. I check in on a few numbers regularly:

  • Hit ratio via memcached-tool 127.0.0.1:11211 stats, looking specifically at get_hits versus get_misses. A sudden drop usually means either a deploy changed your key format without updating both sides, or your expiration times are too aggressive relative to how often content actually changes.
  • Eviction counts. If evictions is climbing steadily, your allocated memory is too small for your working set, and Memcached is discarding still-useful entries to make room for new ones before your application ever gets a chance to serve them from cache.
  • Current connections. A spike here that doesn’t correspond to a real traffic increase can indicate something isn’t closing connections properly, whether that’s Nginx’s keepalive pool misbehaving or a misconfigured client library on the application side.

I usually wire these into whatever monitoring stack is already in place — Prometheus with a Memcached exporter is common, and just about any monitoring tool can scrape memcached-tool output or the raw stats protocol on a schedule.

Frequently Asked Questions

Can Nginx write to Memcached directly, without going through my application?

No. The ngx_http_memcached_module is strictly read-only. Writing to Memcached always has to happen from your application code, a cron job, or some other process that has actual logic for deciding what to cache and for how long. Nginx’s role here is purely to check “does a value exist for this key” and serve it if so.

Does this work with Redis instead of Memcached?

Not with this specific module. Nginx has a separate module, ngx_http_redis_module (or the newer ngx_stream based approaches, plus third-party modules like ngx_http_redis2_module and OpenResty’s Lua-based Redis integration), for talking to Redis directly. The overall pattern — proxy straight to the cache, fall back to the app on a miss — is conceptually the same, but the directives differ.

What happens if Memcached goes down entirely?

Your error_page fallback location handles this gracefully as long as you included 502 in the list of trigger codes, since a connection failure to a down Memcached instance surfaces as a 502 from Nginx’s perspective. Every request just falls through to your application server as if the cache didn’t exist, which is the correct, safe behavior — degraded performance, not a broken site.

Should I use this for a low-traffic site?

Honestly, probably not. The operational complexity of keeping your application’s cache-writing key format in sync with Nginx’s cache-reading key format is real, and for a site that isn’t under heavy load, the performance gain doesn’t usually justify it. This technique earns its keep specifically under sustained high read traffic where shaving application-layer overhead off a large fraction of requests adds up.

Can I use this pattern for session data?

I’d strongly advise against it. Session data is exactly the kind of user-specific, sensitive content that shouldn’t bypass your application’s authentication and authorization checks, which is precisely what this proxy pattern does by design.

Wrapping Up

Proxying directly to Memcached from Nginx is one of those techniques that feels almost too simple once it’s working, but the setup has a few sharp edges — mainly around key matching and security exposure. Once you get the key-naming convention locked down between your application and your Nginx config, the performance payoff is real, particularly for read-heavy workloads under sustained load. I’d recommend rolling this out incrementally: start with one low-risk, high-traffic endpoint, verify your hit ratio and fallback behavior thoroughly, and then expand from there once you’re confident in the setup.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx as a Reverse Proxy for Elasticsearch

How to Set Up Nginx as a Reverse Proxy for Elasticsearch

Next Post
How to Set Up Nginx as a Reverse Proxy for Couchbase

How to Set Up Nginx as a Reverse Proxy for Couchbase

Related Posts