Standing up a second OpenVPN server gives you a second place for clients to connect, and nothing more. It does not put those clients on the first server's network, and the option named client-to-client only changes forwarding inside a single server process. The two pools become one network when you route between them, on purpose, with an owner for each direction. Everything below assumes userspace TUN mode with that internal forwarding left off, so the kernel owns the decision.

The myth: one account list, one network

Authentication and reachability get conflated because both live in the server configuration. A user who can log in to either gateway looks, from the outside, like a user on one system with two doors. Inside, each server hands out addresses from its own pool and installs routes only for the clients it terminates. So a client on gateway A that sends a packet to a client on gateway B is asking for a destination its own server has never heard of. The packet goes to A's default route, leaves the tunnel, and arrives at a firewall that was never asked to permit it.

Adding a second server therefore does not add redundancy, discovery or a shared broadcast domain. It adds a routed network that needs a plan, and the plan starts with addressing.

Give each gateway its own pool and transit address

Two ranges that cannot overlap anything else, plus a small transit link between the hosts, is the whole address plan. Keep the transit on a private segment you control, and write the numbers down before either server hands out a lease.

GatewayClient poolTransit address
gateway-a10.64.10.0/24172.28.44.1/30
gateway-b10.64.20.0/24172.28.44.2/30

Resist the temptation to give both servers the same pool so that profiles stay identical. A kernel route cannot say which of two hosts currently owns an arbitrary address inside a shared range, so the routing table would point at one gateway and the client might be attached to the other. Interface names are local conventions, not shared state. Both hosts here call the tunnel device tun0 and the transit link transit0, which is fine because no packet carries those names.

Install the route on both sides

Each gateway needs a route for the other's pool through the transit address, and forwarding has to be on for either to act as a hop. Confirm the current table first, then add the entry and persist it through the host's network manager rather than a reconnect hook.

sysctl net.ipv4.ip_forward
ip route add 10.64.20.0/24 via 172.28.44.2 dev transit0
ip route get 10.64.20.37

Mirror that on the other host with the pools swapped. Repeatedly appending the same route from an unrelated script is how a host ends up with several copies and a mystery change the next time someone reads the table. If the transit link is itself a tunnel, the route points at that tunnel's topology and the hop must survive its reconnect. A transit address that disappears for thirty seconds takes the inter-pool path with it, which is a design property worth stating rather than discovering.

Push the client route without faking ownership

Clients need a route for the remote pool, otherwise their own operating system sends the packet to its default gateway and the tunnel never sees it. The server configuration is the natural place to deliver that.

push "route 10.64.20.0 255.255.255.0"

Put the mirror line in the other server's configuration, then reconnect a test client and read its table. A client that ignores pushed routes needs its own approved configuration, and a route that never arrives is not an access control. Do not hand a client an iroute for the other gateway's whole pool. That directive exists for a subnet sitting behind one connected client, and using it to paper over a missing inter-gateway route produces a server that believes it owns addresses it cannot reach.

Prove one request and refuse another

Pick a real operation, for example HTTPS from a client at 10.64.10.21 to a service on 10.64.20.37, and permit exactly that on both hosts. Each firewall admits the request on its inbound side and the tracked reply on the return side; the destination's own service policy still has to accept it.

iptables -A FORWARD -i tun0 -o transit0 -s 10.64.10.21 -d 10.64.20.37 -p tcp --dport 443 -j ACCEPT
iptables -A FORWARD -i transit0 -o tun0 -d 10.64.10.21 -s 10.64.20.37 -p tcp --sport 443 -m conntrack --ctstate ESTABLISHED -j ACCEPT

With reciprocal routes, source NAT is unnecessary and actively harmful here: translating the source hides which client made the request and defeats source-based policy on the destination side. Check that an existing masquerade rule for internet egress does not also catch this path. Then test the refusal. A service that should stay local to pool B must remain unreachable from pool A, and same-gateway client pairs need testing too, since the forwarding policy you just changed applies to them as well.

One more correction to the mental model: the transit link carries decrypted inner packets, so it is a trust boundary rather than a private guarantee. If it crosses a network you do not control, protect that hop with an approved encrypted tunnel and route through its real topology. End-to-end TLS still authenticates the application on its own, which is why the inner service should keep using it.

Nuŋlɔɖi siwo ku ɖe eŋu