# Keep the admin panel private

> Take the Frame admin panel off the internet: serve it on a local address and reach it over an SSH tunnel, allow only local clients, or turn it off, and keep Server admin in the apps if you want it.

Source: https://crow.sg/apps/frame/docs/private-admin/

By default the admin panel is served at `/admin` on the same address as the app. That's convenient and the panel is well protected, but if you don't need it on the internet, take it off. Pick one:

| Setting | Effect |
|---|---|
| `FRAME_ADMIN=on` (default) | `/admin` is served next to the app API on the same address. |
| `FRAME_ADMIN_LISTEN=127.0.0.1:8081` | The panel is served **only** on this separate address; the main address answers `/admin` with `404`. Reach it over SSH. |
| `FRAME_ADMIN=local` | `/admin` answers only clients on this machine or in `FRAME_TRUSTED_PROXIES` networks; everyone else gets `404`. |
| `FRAME_ADMIN=off` | No web panel at all. The command line still works. |

`FRAME_ADMIN_LISTEN` and `FRAME_ADMIN=local` can be combined for belt and braces. Restart after changing `frame.conf` (`sudo systemctl restart frame`, or `docker compose up -d`).

> [!NOTE] Keep `FRAME_PUBLIC_URL` set
> Invite links made in a private panel use `FRAME_PUBLIC_URL`. Without it they would point at `localhost`.

## A local address and an SSH tunnel

Serve the panel only on the server's loopback address:

```ini title="frame.conf"
FRAME_ADMIN_LISTEN=127.0.0.1:8081
```

From your laptop, open a tunnel to it:

```sh
ssh -N -L 8081:127.0.0.1:8081 you@your-server
```

Then open `http://localhost:8081/admin/`. Nothing about the panel is exposed to the internet, and because browsers treat `localhost` as a secure context, this works without HTTPS. The first-run setup link also uses this address while `FRAME_ADMIN_LISTEN` is set.

**With Docker**, publish the admin port on the host's loopback only: add `"127.0.0.1:8081:8081"` to the `ports:` of the `frame` service, and set `FRAME_ADMIN_LISTEN=:8081` in `frame.conf`. The SSH tunnel is the same.

## Only from this machine

`FRAME_ADMIN=local` keeps `/admin` on the main address but answers only clients on loopback or in the `FRAME_TRUSTED_PROXIES` networks. Behind a reverse proxy, the real client IP counts, not the proxy's.

> [!WARNING] List a proxy or tunnel on the same host
> A reverse proxy or tunnel on the same machine must be listed in `FRAME_TRUSTED_PROXIES` (for cloudflared, the `cloudflare` keyword). Requests it forwards carry visitor-IP headers, and from an unlisted loopback peer those are refused rather than treated as local.

## Turn it off

`FRAME_ADMIN=off` removes the web panel and the admin API. Manage the server with [`sudo frame-admin`](https://crow.sg/apps/frame/docs/admin-panel/#commands) (or `docker compose exec frame frame-server admin …`). The server logs a warning if `FRAME_ADMIN_APP=on` is also set, because off wins.

## Keep Server admin in the apps

By default (`FRAME_ADMIN_APP=auto`), making the panel private also removes the **Server admin** menu from the apps, so the apps don't open a new way in. To keep it in the apps while the web panel stays private:

```ini title="frame.conf"
FRAME_ADMIN_LISTEN=127.0.0.1:8081
FRAME_ADMIN_APP=on
```

The apps reach the admin API through the normal app API on the main address, with a step-up unlock (your password and two-factor code) bound to the app session. Two-factor setup, password changes and backups stay in the web panel. More in [Server admin in the apps](https://crow.sg/apps/frame/docs/admin-panel/#in-the-apps).
