Skip to content

Admin access & split container

The admin area (everything under /admin) can be isolated from the user portal so a stolen super-admin account alone is not enough — the attacker also needs network access to the admin topology.

Three modes

Mode Who gets in Effort When to use
unrestricted (default) Anyone with a login none Single-person setup, no elevated threat
ip_allowlist Only IPs in your CIDR list 1 setting Office IP / VPN subnet — sufficient for most self-hosters
split_container Only callers reaching the admin hostname / VPN / SSH tunnel high IT firms with many tenants

unrestricted and ip_allowlist are toggled directly under Admin → System → Access. split_container requires Compose work — that's what the setup wizard is for.


Split container — the three setup profiles

When you enable split_container, Vesana runs two API containers (public + admin). How traffic reaches them depends on who terminates TLS in your setup. There are three profiles:

stack-only — stack-internal nginx terminates TLS

Standard self-hoster. The nginx container in the Compose stack listens directly on host port 443 and does TLS itself.

How to recognise it:

# There's an nginx container in the Compose stack:
docker ps | grep nginx
# → vesana-nginx-1 ... 0.0.0.0:443->443/tcp ...

# No additional nginx running on the host:
systemctl status nginx 2>/dev/null | head -1
# → Unit nginx.service could not be found.   OR   inactive (dead)

What the wizard does: writes the admin vhost to the volume /etc/vesana/nginx-overrides/admin-vhost.conf, the stack-nginx loads it on restart via the include. The compose script also publishes the admin port if needed and runs restart nginx itself.

What you do: paste the script, done.

vor-nginx — nginx (or Caddy / Traefik) on the host terminates TLS

You run a reverse proxy outside the Compose stack. It listens on port 443 and forwards HTTP into the stack — either to the stack-internal nginx or directly to the API. Common when several services run on one host (Vesana plus other apps), or when the reverse proxy already existed before Vesana.

How to recognise it:

# An nginx (or Caddy / Traefik) is running as a host service:
systemctl status nginx
# → active (running)

# It listens on port 443:
ss -tlnp | grep ':443 '
# → LISTEN ... users:(("nginx", ...))

# In the Compose stack, nginx is NOT bound to host :443:
docker ps | grep nginx
# → 0.0.0.0:8181->443/tcp   OR   no 443 mapping at all

Caddy / Traefik / HAProxy count the same as "vor-nginx" — the wizard generates nginx vhost snippets, but the concept (reverse proxy on the host, backend in the container) is identical. With Caddy / Traefik you must translate the generated nginx vhost into your own config.

What the wizard does: generates a script that places the vhost file in /etc/nginx/sites-available/<host>-admin, symlinks into sites-enabled and reloads nginx. Plus a compose script that publishes the api-admin container on 127.0.0.1:8082.

What you do: paste the vhost script as root, then paste the compose script. With Caddy / Traefik translate the vhost manually.

bare-metal — no Compose stack

You run Vesana directly via systemd on the host (no Docker). Rare, but it happens. The wizard can't automate much here — you start the second API process yourself.

How to recognise it:

# No api container:
docker ps | grep vesana-api

# Instead: a systemd service:
systemctl status vesana-api
# → active (running)

What you do: create a second systemd unit for api-admin with VESANA_API_MODE=admin on a second port, switch the public API to VESANA_API_MODE=public, route the reverse proxy to the admin port. Plan reference: docs/admin-isolation-plan.md §6.8 variant A.


Which one for me?

Q1 — Is Vesana running in Docker Compose?
    No   →  bare-metal
    Yes  →  Q2

Q2 — Do you have a reverse proxy (nginx / Caddy / Traefik)
     OUTSIDE the Compose stack listening on port 443?
    No   →  stack-only
    Yes  →  vor-nginx

The wizard in the admin portal has auto-detection that gets it right in most cases. If it misdetects, you can override the profile in step 1.


Conflicts & security notes

  • Cookie domain: the admin hostname and the user hostname should share the same parent domain, otherwise the session cookie is lost on switch. The wizard suggests .example.com automatically when both live under *.example.com.
  • Custom port ≠ subdomain: browsers do not isolate cookies by port. Port mode (e.g. :8443) only secures the admin area through permission checks, not through browser origin. If origin isolation matters → subdomain mode + own cert.
  • Emergency bypass: if you lock yourself out (setting split_container but compose override not active yet), the drift fallback kicks in: ADMIN routes are reachable from localhost only. Plus VESANA_ADMIN_BYPASS=true as env var (30 min override) or python -m api.app.cli.admin_recovery reset-mode unrestricted.

Further reading

  • Operator runbook (in repo): docs/split-container-setup.md — step-by-step for variant-A activation, including nginx templates and recovery paths.
  • Plan: docs/admin-isolation-plan.md §6.8 — architecture decisions (variant A/B/C, cookie scope, drift fallback).
  • Permissions: Roles & permissions — how audit_log.view_* and trash.*_*_scope interact with the modes.