[ ] September 1, 2026
Casiana

A database with no way in: connecting a serverless app to a private RDS with no public access

How to access a private database from a serverless app when your compute can't join the VPC. A client required Postgres on RDS with no public access at all, while our Next.js front end ran on managed infrastructure outside the network. This is the pattern that connected the two without weakening the isolation: a database reachable only by an in-VPC Lambda, invoked by IAM permission rather than by network path, plus an on-demand bastion and a fixed egress IP built from the same boundary.

A database with no way in: connecting a serverless app to a private RDS with no public access

Image source: Pexels

Private means no route, not just a closed door

When a client’s security requirements say the database may not be reachable from the internet, the interesting work isn’t securing the database, it’s reaching it. We were building a serverless application: a Next.js front end rendered on AWS Amplify, with its data in a Postgres database on RDS. The requirement was that the database have no public endpoint at all. Those two facts don’t obviously fit together, because the part of our stack that needs the data runs outside the private network the database lives in. This is how we connected them without undoing the isolation that was the whole point.

The requirement is worth stating precisely, because it rules out the usual shortcuts. “Not reachable from the internet” doesn’t mean a public database behind a tight firewall rule; it means no inbound path exists. A public RDS endpoint with an IP allowlist still has a public endpoint, and that was off the table. So the database lives in a private, isolated subnet: no public IP, no route to an internet gateway, outbound traffic disabled, and a security group that accepts connections on port 5432 from a named few resources and nothing else. From the internet’s point of view, the database simply isn’t there.

The usual answers, and why they didn’t fit

Most guides to reaching a private database give the same advice, and it’s good advice: put your compute inside the VPC too. Run your application’s server, or its Lambdas, in a private subnet on the same network as the database, and the problem disappears, you’re already inside the wall. If your stack allows it, that’s usually the simplest path, and adding RDS Proxy alongside it is a sensible way to manage connections and survive failovers. A bastion host solves a narrower version of the problem for humans who need to connect directly, and for stitching together networks you already own there’s VPC peering or PrivateLink.

None of the “just get on the same network” answers were available to us, for one specific reason: our compute couldn’t join the VPC. Amplify’s server-side rendering runs on managed infrastructure whose network placement we don’t control, so “put it in a private subnet” simply wasn’t a lever we had. And even where it is a lever, we weren’t sure we wanted every page render holding a direct line to the database. That constraint is what reframed the question into a more interesting one, and the one this post is really about: how does compute that lives outside the VPC, and can’t be moved inside it, talk to a database that, by design, nothing outside the VPC can reach?

A permission, not a network path

The answer we settled on was to stop thinking about it as a network problem. Instead of giving the front end a route to the database, we gave it permission to ask something inside the VPC to do the work on its behalf.

We placed a single Lambda inside the isolated subnet, the only piece of compute that sits on the same network as RDS and is allowed through its security group on 5432. The front end never opens a connection to Postgres. It invokes this Lambda through the AWS API, authenticated with IAM, and the Lambda runs the query and hands back the result. The database boundary is never crossed by anything but that one function.

Crucially, the Lambda doesn’t accept arbitrary SQL. It exposes a small, fixed set of named operations, and the front end asks for one of them by name:

// Inside the VPC, on the same network as RDS, the only thing that can reach it.
export const handler = async (event) => {
  const { methodName, payload } = event;
  switch (methodName) {
    case "getData":
      return ok(await getData());
    case "createData":
      return ok(await createData(payload.data));
    // …one case per operation the front end is allowed to ask for
    default:
      return { statusCode: 400, body: "Unknown method" };
  }
};

On the other side, the front end calls it with a method name and a payload and awaits the result, the way you would call any function, except this one happens to live inside a network it otherwise has no way to enter.

What this buys is worth spelling out. The database’s only ingress is one security group, on one port, from one function. Authentication is an IAM policy rather than network reachability. If a front-end credential ever leaks, the holder can call the defined set of operations and nothing more, they still have no route to the database and can’t run a query the gateway doesn’t expose. The isolation stays completely intact; we didn’t cut a hole in the wall, we added an authorised doorway with a guard who only accepts a known list of requests.

It isn’t free, and it’s fair to say so. This is an RPC layer you own and maintain: every operation the front end needs is a case in that switch, and every call pays a Lambda invocation and the occasional cold start. For a database that had to have zero public surface, that was an easy trade, and the shape of the code stayed simple.

Letting people in without a standing door

Machines weren’t the only thing that needed access. Engineers still need real connections for migrations and the occasional investigation, and a data-access Lambda is no place to run a schema change by hand.

The tempting solution is a bastion host, a small EC2 instance in the VPC you can jump through. The problem with the usual bastion is that it’s standing: a permanently running box is a permanent attack surface and a permanent bill, and an open SSH port with a shared key is exactly the kind of thing the “no public database” requirement was trying to avoid in the first place.

So we kept the bastion, but not running. Its role, instance profile and security group are defined in our infrastructure code, but the actual instance is brought up only when someone needs it and torn down afterwards. Access is through SSM Session Manager, which means no open SSH port, no key to distribute, and no public IP; the bastion’s security group is granted database ingress only for as long as the instance exists. The default state of the system is that there is no way in for a human at all, access is something you create for a task and then remove, not something that sits waiting to be misused.

The other direction: a fixed egress IP

The same isolated network turned out to solve a mirror-image problem too. Some of the partner APIs we call require requests to come from a known source IP, and serverless egress is normally random, the address changes from one invocation to the next, which is useless to a partner trying to allowlist you.

Because we already had the VPC, the fix was small: a Lambda placed in a subnet with a NAT gateway leaves through a stable outbound IP, which we can hand to a partner to allowlist once and forget. The private network we built to keep traffic out doubles as the thing that gives us a predictable, trustworthy way out.

What running it taught us

One operational detail is worth passing on, because it’s specific to this pattern rather than to us. A gateway Lambda is tempting to write with a single database connection, opened once when the function loads and reused for every call, and in a long-running server that would be exactly right. In Lambda it isn’t. The platform freezes and reuses execution environments between invocations, and a connection that gets dropped in the meantime, by a failover, or a routine maintenance window, won’t be there when the environment wakes up. Hold a single connection and an ordinary database blip turns into an application outage. Use a connection pool that can discard a dead connection and open a fresh one, and attach a handler for its error events, and the gateway rides out the same blips without anyone noticing. It’s the one piece of the pattern we’d tell anyone to get right from day one, because it only reveals itself under real traffic.

Conclusion

Isolation and access read like opposites, and under deadline the instinct is to trade one away for the other, a public endpoint “just for our app,” a bastion left running “just for now.” The move that actually worked was to refuse the trade and change what “access” means instead. The database stays completely unreachable, and reaching it becomes a permission to invoke rather than a path to connect. The result is a system where the database has no way in, the application reaches it on every request, engineers get in when they need to and not a moment longer, and outbound calls leave from an address partners can trust, all from the same boundary. One idea, applied in four directions: put a controlled door inside the wall, instead of a hole through it.