Code Agency
6 min read

The ERP that isn't on the internet: VPN-isolated backends for headless setups

Your storefront is public; your ERP doesn't have to be. WireGuard tunnels, private ingress, and the network layout that keeps Odoo reachable by the API gateway — and nobody else.

We mentioned this in passing when we wrote about headless commerce: take it further, put Odoo entirely behind a firewall or VPN. That line does a lot of work, so this is the deep dive — the actual network layout, the WireGuard config, and the Kubernetes primitives that make "Odoo has no public IP" a fact instead of a slide.

The question worth asking

Every headless project starts with the same instinct: put an API gateway in front of Odoo, done. That solves the contract problem — consumers speak REST to one door, not JSON-RPC to the ORM. It does not solve the network problem, because most teams still give Odoo a Kubernetes Ingress "just in case someone needs to log in." That ingress is a login form, a database manager, and a JSON-RPC endpoint sitting on the open internet, reachable by anyone who finds the hostname.

Nobody outside your company should be able to resolve erp.client.com. Not because the login is weak — because the fewer doors the internet can knock on, the fewer doors you have to guard.

The layout

Internet


CDN ──► Next.js storefront (public)


api.client.com ──► NestJS gateway (public, narrow contract)

                          ▼ cluster-internal only
                    odoo-web-service:8069   (ClusterIP, no Ingress)

                          │ WireGuard tunnel
                    Engineer's laptop / office network

The gateway reaches Odoo over the plain cluster network — it's already inside, no tunnel needed for machine-to-machine traffic. The only thing that needs a VPN is a human who has to open the Odoo admin UI: onboarding a new warehouse, debugging a stuck automated action, approving an invoice run. That is a handful of people, and WireGuard is built exactly for that shape of access.

Odoo gets no Ingress. Full stop.

The Odoo Service stays ClusterIP. No Ingress resource, no LoadBalancer, no annotation that quietly turns it public on the next kubectl apply:

odoo-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: odoo-web-service
  namespace: erp
spec:
  type: ClusterIP # deliberately not LoadBalancer, not NodePort
  selector:
    app: odoo-web
  ports:
    - port: 8069
      targetPort: 8069

A NetworkPolicy then makes the restriction explicit instead of implicit — "no Ingress" is a fact until someone adds one by accident; a policy is enforced by the CNI regardless of what else changes in the namespace:

odoo-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: odoo-allow-gateway-and-vpn
  namespace: erp
spec:
  podSelector:
    matchLabels:
      app: odoo-web
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: gateway
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: vpn

Only pods in the gateway namespace (the NestJS layer) and the vpn namespace (the WireGuard endpoint below) may open a connection to Odoo. Everything else — including other tenants on the same cluster — gets a connection refused, at the network layer, before Odoo's own auth ever runs.

WireGuard: the tunnel, not a product

We reach for WireGuard instead of an IP allowlist for one reason: allowlists trust a network, WireGuard trusts a key. An engineer's home IP changes with every ISP reboot, coffee shop wifi has no fixed address, and "just add the office IP" stops meaning anything the day the office moves to hybrid. A WireGuard peer authenticates with a keypair — it works identically from the office, a hotel, or a phone hotspot, and revoking access is deleting one line from the server config, not tracking down whose home IP to remove.

We run the WireGuard endpoint as its own small deployment inside the cluster, in the vpn namespace the NetworkPolicy above already allows through:

wg0.conf (server, in-cluster)
[Interface]
Address = 10.44.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>
 
# Engineer #1 — laptop
[Peer]
PublicKey = <engineer-1-public-key>
AllowedIPs = 10.44.0.2/32
wg0.conf (client — engineer's laptop)
[Interface]
Address = 10.44.0.2/32
PrivateKey = <engineer-private-key>
 
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.client.com:51820
AllowedIPs = 10.10.0.0/16   # the cluster's pod CIDR — nothing else routes through the tunnel
PersistentKeepalive = 25

AllowedIPs on the client is the important line: it only routes the cluster's internal CIDR through the tunnel, not 0.0.0.0/0. Nobody's laptop should send its entire internet traffic through a corporate VPN to check on a sales order — that's how "the VPN is slow" becomes an all-day complaint instead of a two-minute Odoo session.

Once connected, erp.client.com resolves on a private DNS zone that only exists inside the tunnel. There is no public A record to enumerate, no certificate transparency log entry announcing the hostname, nothing for a scanner to find. The login page a pen tester is looking for does not exist from where they're standing.

The gateway is still the only regular visitor

WireGuard is for the exceptions — a person who needs the actual Odoo UI. It is not how the storefront or the mobile app talk to the ERP; that traffic goes through the NestJS gateway over the ordinary cluster network, all day, at whatever volume the business generates. Routing routine API traffic through a VPN tunnel would add a hop and a single point of failure for zero benefit — the gateway already lives inside the perimeter the tunnel exists to protect.

That split keeps both paths honest: the gateway's narrow, validated contract handles everything a customer or a storefront can trigger; the VPN handles everything a human occasionally needs to click through by hand. Neither path has to compromise for the other.

If you can't avoid public ingress

Some projects genuinely need Odoo reachable without a VPN — a partner integration that can't run a WireGuard client, or a B2B portal built on stock Odoo pages instead of a headless frontend. If that's you, at minimum follow the ingress hardening we use elsewhere: never proxy /web/database, rate-limit /web/login, and put the exposed hostname behind its own strict allowlist of paths. But treat that as the fallback, not the default. The safest login form is the one that isn't reachable from the internet at all.

What it costs

Nothing the business notices. Customers and the storefront never touch the tunnel — they hit the CDN and the gateway, same as always. The only people who feel the VPN exist are the handful of engineers and back-office staff who open Odoo directly, and for them it's a WireGuard client that connects in under a second and stays up all day. In exchange, the entire admin surface of your ERP — every model, every report, every button a misconfigured permission might expose — is invisible to anyone who isn't holding a key you handed them.

This is the network layout behind every headless and Odoo implementation we run on our managed cloud hosting: public where the business needs public, and a locked door with exactly as many keys as there are people who need one.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.