---
title: "How to back up a database with no public IP"
description: "The database that most needs backing up is usually the one nothing can reach. Most tools answer that with an inbound hole or a VPN. There is a better answer, and it is the one your CI already uses."
url: "https://saved.sh/blog/back-up-a-database-with-no-public-ip"
date: "2026-08-08"
author: "saved.sh"
tag: "Engineering"
---

The database you care most about is frequently the one sitting on a private
subnet with no route in from the internet. That is not an accident, it is the
correct configuration, and it is exactly what makes backing it up awkward.

The usual options are all bad in the same way.

| Option                               | What it costs you                                                                              |
| ------------------------------------ | ---------------------------------------------------------------------------------------------- |
| Give the database a public IP        | You undo the decision you made deliberately                                                    |
| Open the port to a vendor's IP range | An allowlist that changes when the vendor's does, and one shared secret away from being a hole |
| Site-to-site VPN                     | Weeks of networking, and a permanent tunnel into your private network                          |
| Bastion with a scheduled SSH job     | You are back to a cron job on a box, with the key material to prove it                         |

Every one of them treats the problem as "how do we get in".

## Invert it [#invert-it]

The pattern that avoids all four is the one your CI runners, your metrics agent
and your log shipper already use: &#x2A;*the thing inside makes an outbound
connection and asks for work.**

Nothing listens. No inbound rule, no allowlist, no tunnel, no public IP. The
database keeps exactly the network position you gave it, and the only firewall
change is the one you almost certainly already permit: outbound HTTPS on 443.

<Figure caption="Nothing reaches in. A worker on your side dials out, takes a job, and does the dump where the database already is.">
  <WorkerTopology />
</Figure>

## How it works here [#how-it-works-here]

You run a small worker process next to the database. On start it dials out and
polls a queue that belongs only to it. When a run is due, the job comes down that
connection, the worker dumps the database locally, and it uploads to your bucket.

Two properties of that arrangement are worth being specific about, because they
are what make it safe rather than merely convenient.

**Your credentials never leave the machine.** The database password is read by
the worker, on your side, from your configuration. It is not stored with us and
it does not travel to us. Same for the bucket credentials.

**Neither do the bytes.** The steps of a run hand each other a *path to a file on
that machine*. The dump does not pass through our orchestrator on its way to your
bucket. Only metadata does: what ran, how long it took, how large the result was,
and where it went.

<Stats>
  <Stat value="0" label="Inbound firewall rules" />

  <Stat value="0" label="Credentials that leave the machine" />

  <Stat value="443" label="The only port involved" />
</Stats>

## The one operational rule [#the-one-operational-rule]

A worker's queue is its identity, and only one process should poll it.

If you run two copies of the same worker configuration, the steps of a single run
can split across two machines, and step two will look for a file that step one
left somewhere else. That is not a subtle failure, it fails loudly, but it is
worth knowing before you scale a deployment to two replicas out of habit.

Run one process per worker identity. If you want a second machine backing up
different things, give it its own identity.

<Aside title="A useful side effect" tone="accent">
  Because the worker runs where the database already is, the dump never crosses the
  network as a database connection. On a large table that is often faster than any
  hosted option, and it does not consume the connection pool your application is
  using.
</Aside>

## What this does not solve [#what-this-does-not-solve]

Being honest about the boundary: an outbound worker does not help if the machine
running it is itself the thing you are worried about. If an attacker owns that
host, they own the database credentials and the worker process together.

That is the argument for the other half of the setup: send the copy to a bucket
in a different account, and turn on Object Lock so that a credential holder
cannot delete what is already written. Outbound polling solves the reachability
problem. It was never going to solve the custody one.
