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.
| Machine | Addresses | Per hour | Monthly equivalent |
|---|---|---|---|
| app (Nano) | IPv4, IPv6, 10.20.0.2 | 0.018 CAD | 13.14 CAD |
| db (Nano, private) | 10.20.0.3 only | 0.014 CAD | 10.22 CAD |
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:
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.
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:
# 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:
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:
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:
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
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:
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:
$ 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:
$ 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.
| Measurement | Command | Result |
|---|---|---|
| Ping, 100 packets 200 ms apart | ping -c 100 -i 0.2 | min 0.247 ms, avg 0.400 ms, max 0.648 ms |
| Flood ping, 5,000 packets | sudo ping -f -c 5000 | avg 0.064 ms, no loss |
SELECT 1, 2,000 in a row | psql to 10.20.0.3 | 187 to 206 µs per query |
| Same, locally on the db machine | psql to 127.0.0.1 | 86 to 87 µs per query |
| Throughput, one stream, 20 s | iperf3 -c 10.20.0.3 -t 20 | 20.6 Gbit/s |
| Throughput, reverse | iperf3 … -R | 18.6 Gbit/s |
| Throughput, four streams | iperf3 … -P 4 | 17.5 Gbit/s |
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:
Host vpc-app
HostName 203.0.113.80
User debian
Host vpc-base
HostName 10.20.0.3
User debian
ProxyJump vpc-app
$ 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:
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, notroot;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 dogoes 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/311answers "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:
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.