Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

How to Connect Two Servers Privately with WireGuard

Published

vpsFirewall and SecurityNetworking
wireguard

When an app server and its database live on separate VPSs, WireGuard can carry their traffic over a private, encrypted link. The database can listen on localhost and its tunnel address instead of a public interface. The public WireGuard endpoint accepts authenticated traffic from the configured peer, while PostgreSQL keeps its own login controls.

This guide connects two Ubuntu 24.04 servers and uses PostgreSQL as the example service. The same tunnel works for MySQL, Redis, or any internal API.

Before you start

You need two Ubuntu 24.04 servers with public IP addresses and sudo access on both. This guide calls them:

Server Public IP Tunnel IP
app APP_PUBLIC_IP 10.10.0.1
db DB_PUBLIC_IP 10.10.0.2

Replace both public IPs and run each step on the named server. The peers need mutually reachable UDP endpoints. Choose a different tunnel subnet if 10.10.0.0/24 overlaps existing routes. Encryption and packet processing use CPU and memory; measure throughput and headroom alongside the application rather than assuming every small VPS has sufficient capacity.

Install WireGuard and create keys

Run these commands on both servers:

$ sudo apt-get update
$ sudo apt-get install -y wireguard-tools
$ sudo sh -c 'umask 077; wg genkey > /etc/wireguard/wg0.key'
$ sudo cat /etc/wireguard/wg0.key | wg pubkey

The last command prints the server’s public key. Note it down for each server. The private key never leaves /etc/wireguard/wg0.key, which only root can read.

Configure the tunnel

Each server gets a small configuration file that names the other server as its only peer. In both files, replace DB_PUBLIC_KEY and APP_PUBLIC_KEY with the public keys you noted, and the IP placeholders with the real addresses.

On app, create /etc/wireguard/wg0.conf:

[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PostUp = wg set %i private-key /etc/wireguard/wg0.key

[Peer]
PublicKey = DB_PUBLIC_KEY
Endpoint = DB_PUBLIC_IP:51820
AllowedIPs = 10.10.0.2/32

On db, create /etc/wireguard/wg0.conf:

[Interface]
Address = 10.10.0.2/24
ListenPort = 51820
PostUp = wg set %i private-key /etc/wireguard/wg0.key

[Peer]
PublicKey = APP_PUBLIC_KEY
Endpoint = APP_PUBLIC_IP:51820
AllowedIPs = 10.10.0.1/32

AllowedIPs does two jobs: it routes that single tunnel address through WireGuard, and it accepts tunnel traffic only from that peer’s key. The PostUp line loads the private key from its own file, so the configuration contains no secrets.

Open the WireGuard port only to the other server. WireGuard already stays silent towards packets that are not signed with a known key, so scanners cannot even tell it is running; the source restriction adds a second layer. On app:

$ sudo ufw allow from DB_PUBLIC_IP to any port 51820 proto udp

On db:

$ sudo ufw allow from APP_PUBLIC_IP to any port 51820 proto udp

Start the tunnel on both servers and enable it at boot:

$ sudo systemctl enable --now wg-quick@wg0
$ sudo wg show

Bring up both peers, then generate traffic from app to the tunnel address. A handshake may not appear until traffic triggers it; after the test, inspect wg show for handshake and transfer counters:

$ ping -c 3 10.10.0.2

Replies mean the tunnel works. Between servers in the same region, the round trip should be close to the normal ping time between their public addresses; WireGuard adds very little.

To monitor the link later, print the time of the last handshake as a Unix timestamp:

$ sudo wg show wg0 latest-handshakes

While traffic flows, the peers renew their session about every two minutes. A timestamp that keeps getting older while the app is busy points to a broken link, which makes this a simple check for a monitoring script.

Keep the tunnel and database controls distinct

A successful ping proves the peer can exchange tunnel traffic; it does not prove PostgreSQL is listening, that its firewall rule permits the connection, or that the database accepts the supplied user. Test those layers in order so a database authentication problem does not lead you to replace otherwise-correct WireGuard keys.

The peer endpoint is a public address used to carry encrypted packets. The tunnel address is the private destination your application uses for PostgreSQL. Do not put DB_PUBLIC_IP back into the app’s database configuration after establishing the tunnel, because that would select the public route instead of the intended private service address.

This is a two-host link, not a full internet VPN. The narrow peer address under AllowedIPs identifies the tunnel traffic for this connection; it is not a request to send all outbound traffic through the other VPS. No internet gateway or cross-subnet forwarding setup is required for the host-to-host database connection shown here.

Move PostgreSQL onto the tunnel

The rest of this guide runs on db, except where noted. If PostgreSQL is not installed yet, install it:

$ sudo apt-get install -y postgresql

Create /etc/postgresql/16/main/conf.d/tunnel.conf so PostgreSQL listens on the tunnel address as well as localhost. Ubuntu 24.04 ships PostgreSQL 16; adjust the version in the paths if yours differs:

listen_addresses = 'localhost,10.10.0.2'

Add this line to the end of /etc/postgresql/16/main/pg_hba.conf. It lets only the app server’s tunnel address log in to one database as one user:

host    appdb    appuser    10.10.0.1/32    scram-sha-256

Create that user and database. The first command asks for the new password:

$ sudo -u postgres createuser --pwprompt appuser
$ sudo -u postgres createdb -O appuser appdb

PostgreSQL must start after the tunnel. Otherwise it can boot first, fail to bind 10.10.0.2, and keep running on localhost only; it does not retry when the tunnel appears. Create a directory for a systemd drop-in:

$ sudo mkdir -p /etc/systemd/system/postgresql@16-main.service.d


Then create /etc/systemd/system/[email protected]/wireguard.conf:

[Unit]
Wants=wg-quick@wg0.service
After=wg-quick@wg0.service


Allow the database port only from the app server, and only through the tunnel. UFW filters wg0 like any other interface, so without this rule the connection times out:

$ sudo ufw allow in on wg0 from 10.10.0.1 to any port 5432 proto tcp
$ sudo systemctl daemon-reload
$ sudo systemctl restart postgresql
$ sudo ss -ltn | grep 5432

The output should include 10.10.0.2:5432 and 127.0.0.1:5432, and no 0.0.0.0:5432.

Test the private connection

On app, install the client and connect through the tunnel:

$ sudo apt-get install -y postgresql-client
$ psql -h 10.10.0.2 -U appuser -d appdb -c "SELECT inet_server_addr(), inet_client_addr();"

After the password prompt, the result shows 10.10.0.2 as the server and 10.10.0.1 as the client. Point your application’s database host at 10.10.0.2.

Now confirm that the database is not reachable over the public network. From app, or from any other machine:

$ nc -zv -w 5 DB_PUBLIC_IP 5432

The public PostgreSQL connection must fail while the intended tunnel connection succeeds. To inspect the tunnel on the actual public interface, filter for udp port 51820 and the peer address. Other traffic between the machines can still be present; a broad host capture is not expected to contain only WireGuard packets.

Finally, reboot db and run the psql test again from app. The tunnel and PostgreSQL come back on their own. The first connection may take a few seconds while the servers repeat their handshake.

For MySQL, Redis, or an internal API, the pattern is the same: bind the service to 10.10.0.2, order it after [email protected], and allow its port on wg0 from 10.10.0.1.

Troubleshooting

  • wg show has no latest handshake line, and ping fails: check that each configuration contains the other server’s public key. Print it on the other server with sudo cat /etc/wireguard/wg0.key | wg pubkey and compare. Then check that UDP 51820 is allowed from the peer’s public IP in UFW and in any firewall your provider runs. Restart the tunnel with sudo systemctl restart wg-quick@wg0 after each change.
  • Ping works, but psql hangs and times out: the UFW rule for port 5432 on wg0 is missing. Check sudo ufw status.
  • no pg_hba.conf entry for host "10.10.0.1": the pg_hba.conf line is missing, or its database or user name does not match. Fix it and run sudo systemctl reload postgresql.
  • After a reboot, ss shows only 127.0.0.1:5432: PostgreSQL started before the tunnel. The log at /var/log/postgresql/postgresql-16-main.log says could not bind IPv4 address "10.10.0.2": Cannot assign requested address. Add the systemd ordering file above, run sudo systemctl daemon-reload, and restart PostgreSQL.

Your two servers now share a private, encrypted network. The database answers on localhost and its tunnel address, while the WireGuard firewall rule permits the configured peer’s public IP.

Reference: WireGuard quick start. Check the documentation for your installed version before applying changes.

Related guides

Account services are unavailable until the production backend is configured.