Private network (VPC): link your VMs off the Internet

An app machine with a public address, a db machine with none, PostgreSQL between them over the private network. We checked the database cannot be reached from the Internet, measured 0.1 ms added per query and 20 Gbit/s of throughput, and managed the database through an SSH jump.

Two machines joined by a private link; only the one on the left has a door to the Internet, the database on the right stays closed

An application and its database on two machines is the most common setup once a project grows a little. The next question comes right away: how does the database take queries from the application without being exposed to the rest of the Internet? A well-tuned firewall is one answer. A database that simply has no public address is a better one.

That is what a private network (VPC) gives you at FFxF. We built the real thing on November 3, 2026: one network, an "app" machine with a public address, a "db" machine with no public address at all, and PostgreSQL installed by hand on the second one. Every output below comes from that test.

1. The setup

  • A private network, 10.20.0.0/24, in Montréal.
  • app: a Nano (1 vCPU, 2 GB, 20 GB) with public IPv4 and IPv6, plus an interface on the private network. It is the only way in.
  • db: a Nano on the private network only, running PostgreSQL 17. No public IPv4, no public IPv6.

Both machines are billed by the hour. The private network itself is free.

The private machine is cheaper: it is priced like a machine without IPv4. On a monthly plan, a Nano is 8.50 CAD.

2. Create the network

In the console, under Private networks, or through the API with a key that has the network.write scope:

bash
curl -s -X POST https://api.ffxf.net/v1/vpcs \
     -H "Authorization: Bearer $FFXF_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"name": "blog-01-vpc-reseau", "cidr": "10.20.0.0/24", "region": "montreal"}'

{"data":{"id":40,"name":"blog-01-vpc-reseau","cidr":"10.20.0.0\/24","region":"montreal",
 "status":"active","gateway":"10.20.0.1","internet_gateway":false,"members":[]}}

The network is active when the call returns. The .1 address is kept for the gateway; machines get the next ones in order. You pick the range, from /16 to /29, inside the usual private blocks, and another customer can use the same range without either network seeing the other. An account can have three networks.

3. Order the two machines

The app machine just gets the vpc field. The db machine also gets "private_only": true.

bash
curl -s -X POST https://api.ffxf.net/v1/vms \
     -H "Authorization: Bearer $FFXF_TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"plan": "nano", "region": "montreal", "image": "debian-13",
          "hostname": "blog-01-vpc-app", "billing": "hourly",
          "ssh_keys": ["34"], "password_delivery": "none", "vpc": 40}'

curl -s -X POST https://api.ffxf.net/v1/vms \
     -H "Authorization: Bearer $FFXF_TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"plan": "nano", "region": "montreal", "image": "debian-13",
          "hostname": "blog-01-vpc-base", "billing": "hourly",
          "ssh_keys": ["34"], "password_delivery": "none",
          "vpc": 40, "private_only": true}'

First trap: ssh_keys takes strings. A key id sent as a number ([34]) is rejected with validation_failed; send ["34"], or the key's SHA256:… fingerprint.

Both machines were running about a hundred seconds after the order. GET /vms/{vm}/network shows the difference:

bash
# app (310)
"ipv4":[{"address":"203.0.113.80", ...}],
"ipv6":[{"address":"2001:db8:100::4c", ...}],
"private":[{"vpc":40,"address":"10.20.0.2","prefix_length":24,"configuration":"automatic"}]

# db (311)
"ipv4":[], "ipv6":[],
"private":[{"vpc":40,"address":"10.20.0.3","prefix_length":24,"configuration":"automatic"}]

Inside the system, the private address is already in place at first boot. On the db machine, the default route goes through the network's gateway:

bash
debian@blog-01-vpc-base:~$ ip -br a; ip route
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             10.20.0.3/24 fe80::60dd:1dff:fe0a:42ff/64
default via 10.20.0.1 dev eth0 proto static
10.20.0.0/24 dev eth0 proto kernel scope link src 10.20.0.3

As soon as a private-only machine joins, the network reports "internet_gateway": true: the gateway lets the db machine reach the Internet through a shared address, for updates and downloads. Outbound port 25 stays closed.

4. Install PostgreSQL on the db machine

You reach the db machine over SSH by jumping through app (details in section 8). Packages come in through the gateway:

bash
sudo cloud-init status --wait
sudo apt-get update
sudo apt-get install -y postgresql

The cloud-init status --wait is there for a reason: right after boot, cloud-init is still running apt, and our first attempt failed with Could not get lock /var/lib/apt/lists/lock. Once cloud-init was done, installing PostgreSQL 17.11 took 1 min 14 s.

Out of the box, PostgreSQL only listens on localhost. We add the private address, and only that one, then allow the private network in pg_hba.conf:

bash
echo "listen_addresses = 'localhost,10.20.0.3'" \
  | sudo tee /etc/postgresql/17/main/conf.d/vpc.conf
echo "host  appdb  app  10.20.0.0/24  scram-sha-256" \
  | sudo tee -a /etc/postgresql/17/main/pg_hba.conf
sudo -u postgres psql -c "CREATE ROLE app LOGIN PASSWORD 'a-real-password';"
sudo -u postgres psql -c "CREATE DATABASE appdb OWNER app;"
sudo systemctl restart postgresql
sudo ss -ltnp | grep 5432
bash
LISTEN 0  200   127.0.0.1:5432   0.0.0.0:*   users:(("postgres",pid=4434,fd=7))
LISTEN 0  200   10.20.0.3:5432   0.0.0.0:*   users:(("postgres",pid=4434,fd=8))
LISTEN 0  200       [::1]:5432      [::]:*   users:(("postgres",pid=4434,fd=6))

Naming the private address instead of * is a habit worth keeping even here: the day this config gets copied to a machine that does have a public address, the database does not end up exposed by accident.

5. The app reaches the database

The app machine only needs the client (apt-get install -y postgresql-client). It connects to the private address:

bash
debian@blog-01-vpc-app:~$ psql "host=10.20.0.3 dbname=appdb user=app" \
  -c "SELECT id, client, total, inet_server_addr() AS serveur,
             inet_client_addr() AS client_ip FROM commandes;"

 id |  client  | total |  serveur  | client_ip
----+----------+-------+-----------+-----------
  1 | Tremblay | 42.50 | 10.20.0.3 | 10.20.0.2
(1 row)

PostgreSQL sees the connection coming from 10.20.0.2, the app's private address. In your code, the connection string simply points at 10.20.0.3:5432.

6. Is the database really unreachable?

During the test, the db machine was also running an iperf3 server listening on every interface (*:5201), so any path in from outside would show. First try, from another of our servers, a direct neighbour in the same public range but outside the private network:

bash
$ ip route get 10.20.0.3
10.20.0.3 via 23.159.52.1 dev eno1 src 23.159.52.5
$ timeout 5 bash -c '</dev/tcp/10.20.0.3/5432'; echo $?
124

10.20.0.3 does not exist outside the private network: the packet follows the default route and goes nowhere (timeout, exit code 124). That leaves the shared address the db machine uses to reach the Internet, here 23.159.52.25. Tried from a machine at another provider:

bash
$ timeout 6 bash -c '</dev/tcp/23.159.52.25/5432'
bash: connect: Connection refused
$ timeout 6 bash -c '</dev/tcp/23.159.52.25/5201'
bash: connect: Connection refused

Neither PostgreSQL nor iperf3 answers there. That address does answer on SSH, but it is not our machine: its host key (SHA256:a977NC5N…) differs from the db machine's (SHA256:Liteob51…). It is the shared gateway, and it forwards nothing inward.

On the app side, port 5432 on the public address is closed too, since PostgreSQL does not run there. For that machine, a cloud firewall that only lets in 22, 80 and 443 is still a good idea. It does not filter the private network, so it will not get in the way of database traffic.

7. Measured latency and throughput

Every measurement ran from app to db, over the private address, with no system tuning.

So the network adds roughly 0.1 ms per round trip to a SQL query. That is nothing for a page that runs a handful of queries, and it starts to show on a page that fires hundreds of them one after another. In that case, batch the queries rather than looking at the network.

The spaced-out ping looks slower than the flood because an idle machine takes a moment to wake up for each packet. The throughput figures are what two 1-vCPU machines got on our infrastructure that day: an order of magnitude, not a guarantee.

One thing you can check in the API: about 140 GB crossed the private network during these tests, and the app machine's traffic counter read 0.01 GB afterwards. Private traffic does not count against the 1 TB allowance. What leaves through the gateway does: the db machine showed 0.1 GB, roughly its apt downloads.

8. Managing the private machine

With no public address, you reach the db machine through app. OpenSSH does this on its own with ProxyJump. In ~/.ssh/config on your computer:

bash
Host vpc-app
    HostName 203.0.113.80
    User debian

Host vpc-base
    HostName 10.20.0.3
    User debian
    ProxyJump vpc-app
bash
$ ssh vpc-base 'hostname; uptime'
blog-01-vpc-base
 15:34:44 up 5 min,  2 users,  load average: 0.30, 0.37, 0.18

The same SSH key, set at order time, works for both machines. No private key is copied to app: the jump carries the encrypted session all the way to the db machine. As a one-liner, without a config file: ssh -J debian@203.0.113.80 debian@10.20.0.3.

To open the database in a desktop tool from your computer, a tunnel is enough:

bash
ssh -f -N -o ExitOnForwardFailure=yes -L 15432:10.20.0.3:5432 vpc-app
psql "host=127.0.0.1 port=15432 dbname=appdb user=app" \
  -Atc "select client, total, inet_client_addr() from commandes"

Tremblay|42.50|10.20.0.2

PostgreSQL sees the connection coming from 10.20.0.2, so the private network rule in pg_hba.conf already covers it. Nothing else to open.

9. Gotchas and limits

  • The Debian 13 image logs in as debian, not root; ssh root@… prints a message telling you so.
  • The private interface has a 1450-byte MTU, set automatically. A 1,422-byte ping with -M do goes through, a 1,423-byte one does not. Nothing to change, but worth knowing if you run a tunnel on top.
  • A machine with no public address cannot leave its only network: DELETE /vpcs/40/members/311 answers "This machine has no public address: its private network is its only access".
  • A network that still holds machines cannot be deleted: 409, "Delete the machines on this network first".
  • A network lives in one region. A machine can only join a network in its own region.
  • We did not turn on TLS in PostgreSQL for this test. The network is isolated from other customers; if your policy requires end-to-end encryption, add it on the database as you would anywhere else.

10. Tear it all down

Machines first, then the network. Deleting a machine requires its hostname in confirm, a guard against a mistyped id in a script:

bash
curl -s -X DELETE -H "Authorization: Bearer $FFXF_TOKEN" \
     "https://api.ffxf.net/v1/vms/311?confirm=blog-01-vpc-base"
curl -s -X DELETE -H "Authorization: Bearer $FFXF_TOKEN" \
     "https://api.ffxf.net/v1/vms/310?confirm=blog-01-vpc-app"
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
     -H "Authorization: Bearer $FFXF_TOKEN" https://api.ffxf.net/v1/vpcs/40
204

About fifteen seconds after the machines were deleted, the network was empty and deleting it returned 204. The whole test, from creating the network to deleting it, took nine minutes and cost 0.032 CAD: one started hour per machine, at 0.018 and 0.014 CAD.

One incoming stream shared by a load balancer across three machines, one drained

Load balancer: several servers behind one address

A load balancer takes traffic on its own address and shares it across your machines. Pools and health checks, an HTTPS listener with an automatic certificate, path routing, maintenance without downtime: a complete setup, at 0.010 CAD an hour.

Read the article 8 min read

An IPv6 VM linked through a translation gateway to the IPv4 Internet

IPv6-only VPS: 2 CAD less, IPv4 still in reach

No public IPv4, 2 CAD less a month, and GitHub still in reach: a demo of an IPv6-only VPS, and of the gateway that links it to the IPv4 Internet.

Read the article 6 min read

Support & discussions

Technical questions, incident reports, or infrastructure discussions, the team is reachable on Discord, Telegram, X, Instagram, Reddit, and IRC.