For Developers

Put your Odoo behind a tailnet so the public internet never sees it

If only staff use your Odoo, the public internet doesn't need to. Bind to the Tailscale interface, close 443, and stop fighting /web/login brute force.

The threat model for a typical self-hosted Odoo looks like this: a public IP, nginx terminating TLS, /web/login reachable from anywhere, and a fence of strong passwords plus 2FA holding the rest of the internet at bay. That fence works until it doesn’t — a credential-stuffing run lands on a recycled password, a custom module ships an unauthenticated endpoint, or someone leaves /web/database/manager open with the default master password. None of these matter if the port isn’t reachable.

That’s the trick worth thinking about more often than people do. For a single-company internal Odoo — the kind where every user is a known employee with a managed device — you can stop hardening the front door and remove the door from the public street. Bind Odoo to the Tailscale interface, close 80 and 443 on the public firewall, and reach it only through the tailnet. This post walks through what that looks like for Odoo 18 and 19, and is honest about what you give up.

When this is the right architecture

It’s right when every Odoo user is also a known device. Internal ERP for a 5- to 200-person company. Backoffice for a shop. The accounting and HR side of a business whose marketing site is a separate static thing on Cloudflare Pages.

It’s wrong the moment you need anonymous public users — B2C ecommerce, a marketplace, a partner portal that customers self-sign-up to, webhook receivers from Stripe that fire from arbitrary IPs. For those, keep a slim public surface (a separate site, or Tailscale Funnel on specific routes) and put the rest behind the tailnet.

Cost reality: Tailscale’s free tier covers 100 devices and 3 users. Past that it’s $6/user/month at time of writing. That’s the actual trade-off versus a public deployment, not the technical effort.

What “private Odoo on a tailnet” actually means

After the change:

  • Tailscale is installed on the Odoo VM and on every employee’s laptop and phone.
  • Odoo’s process binds to 127.0.0.1. It does not listen on 0.0.0.0.
  • The public firewall blocks 80, 443, 8069, 8072, 5432 — everything except SSH from your management bastion (or SSH also closed, with tailscale ssh instead).
  • DNS for odoo.example.com either doesn’t exist publicly, or resolves to a CNAME of odoo.your-tailnet.ts.net. MagicDNS handles resolution inside the tailnet.
  • TLS is real Let’s Encrypt, issued by Tailscale for the ts.net hostname.

A laptop without Tailscale running cannot resolve, cannot route to, cannot reach Odoo. That’s the entire point.

The minimum working configuration

On the Odoo host, stop binding to the world. Both Odoo 18 and 19 expose two HTTP ports — the main port and the gevent port that handles longpolling and the OWL 2 websocket bus. Bind both to localhost in /etc/odoo/odoo.conf:

[options]
http_interface = 127.0.0.1
http_port = 8069
gevent_port = 8072
proxy_mode = True
list_db = False
admin_passwd = $pbkdf2-sha512$25000$...
workers = 4

proxy_mode = True is required when something else (nginx, Tailscale serve) terminates TLS — it tells Odoo to trust X-Forwarded-* headers. list_db = False plus a hashed admin_passwd is non-negotiable even on a tailnet, because /web/database/manager is too dangerous to leave open even to authenticated employees by accident.

The gevent port matters more than people realise. Odoo 16 used a separate longpolling_port. Odoo 18 reworked this as gevent_port to carry the OWL 2 websocket bus alongside longpolling. Odoo 19 keeps gevent_port but sharpens the proxy expectations — the reverse proxy must forward /websocket and /longpolling/* to it without buffering. Skip this and chat, the discuss module, and any live update silently break, while the rest of the UI works fine. It’s a confusing failure mode.

Now block the public ports with ufw:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 8069
sudo ufw allow in on tailscale0 to any port 8072
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 22/tcp comment 'remove once tailscale ssh is verified'
sudo ufw enable

The in on tailscale0 form is the key — it allows traffic only from the Tailscale interface, regardless of destination address.

TLS: the easy way and the flexible way

The easy way is Tailscale serve, which proxies a tailnet HTTPS port to a local backend with a real Let’s Encrypt cert managed for you. For Odoo this is fine if you don’t need chat or live updates:

sudo tailscale cert odoo.your-tailnet.ts.net
sudo tailscale serve --bg --https=443 http://127.0.0.1:8069

Browser hits https://odoo.your-tailnet.ts.net, green padlock, no certbot, no nginx. The catch is path-based routing: chat needs /websocket and /longpolling/* going to 8072 instead of 8069. The Tailscale serve flag surface for path splits has shifted across versions, so check tailscale serve --help on your installed version rather than copying a year-old example. If you can’t get the split to work cleanly, fall back to the nginx approach below — it’s more flexible anyway.

The flexible way is nginx, bound only to the tailnet interface:

upstream odoo {
    server 127.0.0.1:8069;
}
upstream odoo_chat {
    server 127.0.0.1:8072;
}

server {
    listen 100.64.0.10:443 ssl http2;
    server_name odoo.your-tailnet.ts.net;

    ssl_certificate     /etc/ssl/odoo.your-tailnet.ts.net.crt;
    ssl_certificate_key /etc/ssl/odoo.your-tailnet.ts.net.key;

    location /websocket {
        proxy_pass http://odoo_chat;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 720s;
    }

    location /longpolling/ {
        proxy_pass http://odoo_chat;
        proxy_read_timeout 720s;
    }

    location / {
        proxy_pass http://odoo;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP         $remote_addr;
    }
}

Replace 100.64.0.10 with the actual tailnet IP from tailscale ip -4. Get the cert files with sudo tailscale cert --cert-file=/etc/ssl/odoo.your-tailnet.ts.net.crt --key-file=/etc/ssl/odoo.your-tailnet.ts.net.key odoo.your-tailnet.ts.net and add a renewal cron — Tailscale issues 90-day certs and does not auto-renew them through this path.

What breaks, and what to do about it

This architecture is hostile to anything that involves an anonymous public user or an unsolicited public request. The casualties:

Portal users. If customers log into /my/orders to see invoices, they need to be on the tailnet — which they aren’t. Either move to email-delivered PDF invoices, or accept that “portal” becomes “internal staff lookup.”

The public website module. Same issue. If /shop is supposed to be Google-indexable, this is the wrong architecture for the whole instance. A common pattern is a separate stripped-down public Odoo (or a static site) for the marketing surface, and a private tailnet Odoo for the real business data.

Webhooks. Stripe, GitHub, your payment provider — they fire from public IPs. Tailscale Funnel can expose specific paths (/webhook/stripe) publicly while keeping the rest private. Be deliberate about which paths you expose, and treat each as an unauthenticated public endpoint regardless of the rest of the architecture.

Mobile users on hotel Wi-Fi without the Tailscale app. This is a non-issue in practice — install the app, MagicDNS works on iOS and Android — but it’s the most common pushback in the planning meeting. Worth naming up front.

Inbound mail. Outbound SMTP still works. The mail gateway / fetchmail flow needs an SMTP server somewhere; the usual answer is keep the mail host public-facing and have it forward processed mail to Odoo over the tailnet, rather than have Odoo itself listening on port 25.

The pattern: anything that needs an anonymous third party to initiate a connection to Odoo doesn’t fit. Everything employee-initiated does.

The database manager, again

Worth repeating because it gets missed: even on a tailnet, with list_db = True and the default admin_passwd, anyone on your tailnet can drop your production database from a browser. That includes a contractor’s laptop you onboarded three months ago and forgot to remove. Set list_db = False, generate a real admin_passwd with python -c "from passlib.context import CryptContext; print(CryptContext(['pbkdf2_sha512']).hash('your-real-password'))", and put the hash in odoo.conf. The tailnet is a perimeter, not a license to skip basics.

What you actually gained

Brute-force runs on /web/login cannot reach you. A zero-day in a custom module that exposes an unauthenticated endpoint cannot be hit from outside the tailnet. The database manager cannot be reached by a stranger. The threat model collapses from “everyone on the internet” to “everyone on your tailnet” — and if a device on your tailnet is compromised, you have a different and more tractable problem than a botnet in Belarus guessing passwords.

This is the architecture for “we are 30 people on Odoo and nobody outside the company should ever touch it.” It doesn’t show up in hosting comparison posts because there’s no hosting bill to upsell, and it doesn’t show up in security checklists because it’s an architecture choice rather than a hardening tip. But it’s the right answer for more deployments than the public-IP-plus-fail2ban template suggests.

odoo-18odoo-19deploymentsecuritytailscale