RedisRailwaySystemsInfrastructureNetworking

Building Managed Redis From Scratch

Redis takes one command to run, so why does every cloud bill you monthly for a managed one? I built it on a platform that does not sell one, to find out what you are actually paying for.

July 26, 20268 min read

Redis takes one command to run.

bash
1docker run -p 6379:6379 redis:7-alpine

That is it. It is running.

So why does every cloud sell you a managed Redis, and bill you monthly for it?

For most teams, buying it is the right call. It works, it is someone else's problem at 3am, and you ship faster. Buy it.

But it is worth knowing what you are paying for. Because it is not Redis.

What The Button Is Actually Doing

To find out where the difficulty lives, I built managed Redis on a platform that does not sell one. Railway has a general-purpose API and no Redis product. No button, no SDK. Every piece had to be wired by hand.

Four things go behind that button:

  • Compute. A container running redis-server.
  • Storage. A volume, so the data survives a restart.
  • Networking. An address you can reach from outside the platform.
  • Lifecycle. Create it, wait for it, tear it down without leaving scraps.

That is the product. Not the database. The database is free.

You are paying someone to wire those four together, and to keep them running.

A Volume Is Not Persistence

This one caught me out immediately.

Mounting a disk at /data does not make Redis write to it. Redis holds everything in memory. It only persists if you tell it to.

So the start command carries the real weight:

bash
1redis-server --requirepass <password> --appendonly yes --dir /data

--appendonly yes turns on the append-only file. --dir /data puts that file on the mounted volume.

Miss either flag and you have a database with a disk attached that still loses everything on restart.

I was not consistent about this myself. My local Docker backend mounts the same path but never passes those flags, so it falls back to the image's default snapshotting instead of AOF. Two backends, two different durability guarantees.

That is a decision a provider makes for you. You never see it.

Two Conversations, Not One

Talking to a database is two separate conversations. They never touch.

Managing the box is one. Create it, attach a volume, open a port, delete it. Rare. Over HTTP.

Using the box is the other. SET, GET, PING. Constant. Over a raw TCP socket.

Control plane and data plane.

The code ended up drawing that line by itself. The interface both backends implement has two methods:

src/provisioner.ts
1interface RedisProvisioner {2  provision(name: string): Promise<{ id: string; url: string }>;3  destroy(id: string): Promise<void>;4}

Those two are the entire control plane.

The data plane is the url that comes back. It never touches this interface again.

Diagram splitting the control plane (HTTP and GraphQL to the Railway API, which builds a container, a volume and a TCP proxy) from the data plane (raw TCP carrying RESP through the proxy into Redis).
Same Redis, two protocols. One conversation builds the box, the other uses it.

The reason to care is not vocabulary. It is triage.

When something breaks, ask which conversation died. That one question tells you how bad it is:

  • Control plane down -> nobody provisions anything new -> every database already running keeps serving traffic -> no user notices.
  • Data plane down -> your app is failing reads right now.

Same product. Completely different severity.

Providers build the two as separate systems partly so that one cannot take the other down with it.

Redis Does Not Speak HTTP

TCP, HTTP and GraphQL are not three options. They are a stack. TCP moves bytes and has no opinion about what they mean. HTTP is one language spoken over it. GraphQL is a convention for using HTTP.

Redis sits beside HTTP, not on top of it. It defines its own protocol, RESP, straight over TCP.

So the URL Railway returns is not a web address:

text
1redis://default:•••@tokaido.proxy.rlwy.net:35111

You cannot open it in a browser or fetch() it. It is a host and a port for a socket a client opens and holds.

RESP is small enough to speak by hand. The readiness check has no Redis client in it, just a socket and two inline commands:

the readiness probe
1AUTH <password>2PING3   -> +OK +PONG

If +PONG comes back, a real Redis is on the other end. That is the entire probe.

The Container Has No Public Address

Locally this never comes up. docker run -p 6379:6379 and you are done.

Railway containers are private by default. Correct posture, but it means there is no address your machine can dial. Not the wrong protocol. No route at all.

So something public has to sit in front and pass connections inward. That is a TCP proxy.

It reads nothing. It accepts a connection on a public host and port, then forwards the bytes unchanged into the private container. Nothing in the path translates anything, because nothing needs to.

tcpProxyCreate takes applicationPort: 6379 and hands back { domain, proxyPort }. Whatever arrives at that public port comes out at 6379 inside.

Same primitive as -p 6379:6379 on a laptop. Different mechanism.

Finding A Mutation The Docs Do Not Mention

Railway documents adding a TCP proxy by clicking a button in the dashboard. It does not document the API call behind that button.

That is a problem when scripting it is the entire point.

GraphQL has an escape hatch. The spec requires every server to be able to describe its own schema, through meta-fields that start with __.

So instead of hunting through docs, ask the API what it can do:

introspection
1query {2  __type(name: "Mutation") {3    fields { name args { name type { kind name } } }4  }5}

That printed tcpProxyCreate, its input shape of { environmentId, serviceId, applicationPort }, and a return type of { domain, proxyPort }. Exactly the public endpoint needed. Undocumented, and sitting in the schema the whole time.

The errors turn into a second source of truth:

  • Cannot query field "x" -> the field does not exist.
  • Not Authorized, or a missing-argument complaint -> it exists and you called it wrong.

That distinction is useful when you are guessing. And it works on any GraphQL API. GitHub, Shopify, Linear.

The schema explorers everyone uses are a UI over these same queries.

Six Mutations, In An Order That Matters

With the schema in hand, provisioning comes out as six mutations. The ordering is most of the work. You cannot attach a volume to a service that does not exist, and you cannot proxy a port with nothing behind it.

The six-mutation provisioning sequence: projectCreate, serviceCreate, serviceInstanceUpdate, volumeCreate, tcpProxyCreate, serviceInstanceDeployV2, then polling until PONG, with cleanup on failure.
The sequence, plus the two things a first draft forgets: waiting for ready, and cleaning up on failure.

One thing to watch. projectCreate returns its environments as a GraphQL connection, so the environment ID comes out of environments.edges[0].node.id. A fresh project has exactly one environment, so index zero is safe here. Anywhere that mattered, I would look it up by name.

Created Does Not Mean Ready

The create call returns immediately. Redis is not up.

The platform is still pulling an image and booting a container. Hand back the URL at that moment and the caller's first SET hits a refused connection.

So you poll. Retry the RESP ping until it answers.

What surprised me was how differently patient the two backends have to be:

  • Local Docker: 20 attempts, 500ms apart. About ten seconds.
  • Railway: 60 attempts, 3 seconds apart. Three minutes.

An 18x gap, and it comes down to one line.

Locally the image gets pulled in its own step, before the clock starts. On Railway the platform pulls it during deploy, inside the window being timed.

So the retry budget is not measuring Redis at all. It is measuring an image pull in someone else's data center.

A one-minute ceiling was my first guess. It was not enough.

The same problem shows up everywhere. Kubernetes readiness probes. Load balancer health checks. Docker healthchecks. They exist because creation and readiness are separate events, and only one of them is easy to detect.

The Part That Costs You Money

Provisioning is a transaction with no rollback.

Step four of six fails and you have already created a project, a service, and a volume. They exist. They bill you. They belong to nothing.

So from the moment the project exists, everything after it runs under a guard:

src/railway.ts
1const { projectCreate } = await this.gql(/* create project */);2try {3  // serviceCreate, serviceInstanceUpdate, volumeCreate,4  // tcpProxyCreate, serviceInstanceDeployV25  await retry(() => ping(domain, proxyPort, password, 4000), { attempts: 60, delayMs: 3000 });6  return { id: projectCreate.id, url };7} catch (err) {8  await this.destroy(projectCreate.id).catch(() => undefined); // one projectDelete takes it all9  throw err;10}

The first real run hung on a cold start and left a project alive.

A hang is not an exception. The catch never fired. The guard could not help, because it only catches failures that throw.

I found the orphan by listing projects, and deleted it by hand.

The honest fix is a timeout around the whole provision, not just around the ping, so that stuck becomes a throwable failure like everything else. Right now the retry ceiling bounds the polling and nothing bounds the five mutations before it.

What Managed Was Really Hiding

Both backends implement the same two methods, so the differences read side by side. The sharpest one is not in provisioning. It is in teardown.

On Railway, destroy is a single mutation. projectDelete. The container, the volume, and the proxy all go with it.

On Docker, you inspect the container to find its mounts, remove the container, then remove each volume separately and tolerate a failure on each one.

A managed service is not just the wiring. It is owning the whole set as one unit, so that deleting it cannot leave debris behind.

Provisioning is the easy half. Guaranteeing nothing survives the delete is the half you are paying for.

What You Are Really Paying For

Not Redis. Redis is free.

Compute. Storage. Networking. Lifecycle. Wired together by someone else.

And the point of building it by hand is not that you would ship your own. You would not. Buy the managed one.

The point is what you keep afterwards.

You have a first question when something breaks. Control plane or data plane. That tells you whether users are affected before you open a single log.

You walk a broken connection from the bottom up, checking whether TCP connects at all before you start suspecting auth or protocol.

You stop trusting that created means usable in any API.

And when the docs come up short on a GraphQL API, you introspect it instead of guessing.

Source code: github.com/intojhanurag/redis-as-a-service

Anurag Ojha

Anurag Ojha

TypeScript nerd, building an AI agent framework for developers at Mastra. In my free time, I love to contribute to open source software that I use.