A self-hosted web UI for managing one or more Outline VPN servers. Built with Next.js.
Outline's official manager is a desktop app — you're stuck on one machine to manage keys. This gives you a browser-based dashboard accessible from any phone, tablet, or laptop. It also supports multiple Outline servers from a single interface.
- Multi-server dashboard — manage multiple Outline servers from one place with live status cards
- Add and delete access keys from any browser
- Inline rename for keys and server names — editable fields are highlighted in cyan
- Per-key data limits — set, update, or remove inline
- Default server limit — view and edit the server-wide default data limit from Server Details
- Data usage per key with visual progress bars
- Traffic share breakdown across all keys
- Sortable table — click any column header to sort by name, data used, traffic share, last active, or devices
- Metrics toggle — enable or disable anonymous Jigsaw telemetry directly from the dashboard header
- Last Active per key — when each key last had traffic (requires Outline Server v1.2+)
- Peak device count per key — max simultaneous devices recorded in the last 24h (requires Outline Server v1.2+)
- QR codes for easy mobile key sharing
- Server info panel: hostname, version, port, cipher, uptime, created date
- Flags top consumer, unused keys, and keys over their limit
- Dark mode support
- Authentication via Authelia + Traefik
Set OUTLINE_API_URL in .env and run the app. On first start, data/servers.json is auto-created with that server pre-loaded. You'll land on the dashboard with one server card ready to go — no extra setup needed. This means anyone who forks this repo to manage one server can just set their env and go.
Add servers through the dashboard UI by pasting their API URLs. Each server is saved in data/servers.json. The API URLs (which contain your secret tokens) are stored only on disk — they are never sent to the browser. The client only ever receives safe fields: id, name, addedAt.
/ → Dashboard — all servers, live status, add/remove
/server/[id] → Detail page — keys, usage, limits, rename
| Layer | Detail |
|---|---|
| Not web-accessible | data/ is outside public/ — Next.js never serves it over HTTP |
| File permissions | Written with mode 0600 — owner read/write only |
| Directory permissions | data/ directory is mode 0700 |
| Never sent to browser | All API routes call safeServer() which strips apiUrl before responding |
| Gitignored | data/ is in .gitignore — can never be accidentally committed |
| Docker volume | In production, data/ is a named volume — never baked into the image |
| Read-only container | read_only: true prevents writes anywhere except the data volume and /tmp |
OUTLINE_API_URLlives in.env(server-side only). Never useNEXT_PUBLIC_prefix..envis excluded from the Docker image via.dockerignore.- Secrets are injected at container runtime via
env_fileindocker-compose.yml.
- Node.js 18+
- An Outline server and its Management API URL
Finding your API URL: In the Outline Manager desktop app, click the three-dot menu → "View server config" → copy the
apiUrlvalue. It looks likehttps://1.2.3.4:39992/sometoken.
git clone https://github.com/chitminthu/outline-manager-web
cd outline-manager-web
npm install
npm install qrcode.reactCreate .env:
OUTLINE_API_URL=https://your-server-ip:port/your-api-prefix
AUTH_ENABLED=falseMake sure data/ is in your .gitignore:
data/
Run:
npm run devOpen http://localhost:3000.
data/servers.json is created automatically on first run. To add more servers, click + Add Server on the dashboard.
ssh root@your-server
apt update && apt install -y docker.io docker-compose-plugin ufw
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enablemkdir -p /opt/outline-stack/{traefik/certs,authelia/{config,secrets},app,data}
cd /opt/outline-stack/opt/outline-stack/
├── docker-compose.yml
├── data/ ← servers.json lives here (mounted as volume)
├── traefik/
│ ├── traefik.yml
│ ├── dynamic.yml
│ └── certs/acme.json
├── authelia/
│ ├── config/
│ │ ├── configuration.yml
│ │ └── users_database.yml
│ └── secrets/
└── app/ ← git clone goes here
├── .env
└── Dockerfile
git clone https://github.com/chitminthu/outline-manager-web /opt/outline-stack/appcat > /opt/outline-stack/app/.env << 'EOF'
OUTLINE_API_URL=https://your-server-ip:port/your-token
AUTH_ENABLED=true
NODE_ENV=production
EOF
chmod 600 /opt/outline-stack/app/.envapp/Dockerfile:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV OUTLINE_API_URL=http://placeholder
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]app/.dockerignore:
.env
.env.*
.env.local
node_modules
.next
data/
next.config.js:
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;traefik/traefik.yml:
api:
dashboard: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
filename: /dynamic.yml
certificatesResolvers:
letsencrypt:
acme:
email: your@email.com
storage: /certs/acme.json
httpChallenge:
entryPoint: webtraefik/dynamic.yml:
http:
middlewares:
authelia:
forwardAuth:
address: "http://authelia:9091/api/verify?rd=https://auth.yourdomain.com"
trustForwardHeader: true
authResponseHeaders:
- Remote-User
- Remote-Groups
- Remote-Name
- Remote-Emailtouch /opt/outline-stack/traefik/certs/acme.json
chmod 600 /opt/outline-stack/traefik/certs/acme.jsonauthelia/config/configuration.yml:
theme: dark
server:
address: 'tcp://0.0.0.0:9091'
log:
level: info
authentication_backend:
file:
path: /config/users_database.yml
access_control:
default_policy: deny
rules:
- domain: outline.yourdomain.com
policy: one_factor
- domain: auth.yourdomain.com
policy: bypass
session:
cookies:
- name: authelia_session
domain: yourdomain.com
authelia_url: https://auth.yourdomain.com
storage:
local:
path: /config/db.sqlite3
notifier:
filesystem:
filename: /config/notifications.txtGenerate a password hash:
docker run authelia/authelia:latest authelia crypto hash generate argon2 --password 'yourpassword'authelia/config/users_database.yml:
users:
youruser:
displayname: "Your Name"
password: "$argon2id$..."
email: your@email.com
groups: []Authelia secrets:
openssl rand -hex 32 > /opt/outline-stack/authelia/secrets/jwt_secret
openssl rand -hex 32 > /opt/outline-stack/authelia/secrets/session_secret
openssl rand -hex 32 > /opt/outline-stack/authelia/secrets/storage_encryption_key
chmod 600 /opt/outline-stack/authelia/secrets/*networks:
proxy:
external: true
services:
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
networks: [proxy]
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/traefik.yml:/traefik.yml:ro
- ./traefik/certs:/certs
- ./traefik/dynamic.yml:/dynamic.yml:ro
authelia:
image: authelia/authelia:latest
container_name: authelia
restart: unless-stopped
networks: [proxy]
volumes:
- ./authelia/config:/config
- ./authelia/secrets:/secrets
environment:
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/jwt_secret
- AUTHELIA_SESSION_SECRET_FILE=/secrets/session_secret
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/storage_encryption_key
labels:
- "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`auth.yourdomain.com`)"
- "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls.certresolver=letsencrypt"
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
outline-app:
build:
context: ./app
dockerfile: Dockerfile
image: outline-manager:latest
container_name: outline-app
restart: unless-stopped
networks: [proxy]
env_file: ./app/.env
volumes:
- ./data:/app/data # persists servers.json across container rebuilds
read_only: true # container filesystem is read-only except data/ and /tmp
tmpfs:
- /tmp # Next.js needs a writable /tmp at runtime
labels:
- "traefik.enable=true"
- "traefik.http.routers.outline.rule=Host(`outline.yourdomain.com`)"
- "traefik.http.routers.outline.entrypoints=websecure"
- "traefik.http.routers.outline.tls.certresolver=letsencrypt"
- "traefik.http.services.outline.loadbalancer.server.port=3000"
- "traefik.http.routers.outline.middlewares=authelia@file"The container runs as uid 1001 (nextjs). The host data/ folder must be owned by the same uid — if it's owned by root, the container will get Permission denied when reading or writing servers.json.
chown -R 1001:1001 /opt/outline-stack/data
chmod 700 /opt/outline-stack/data
chmod 600 /opt/outline-stack/data/servers.jsondocker network create proxy
cd /opt/outline-stack
docker compose build --no-cache outline-app
docker compose up -d
docker compose logs -fcd /opt/outline-stack/app && git pull
cd /opt/outline-stack
docker compose build --no-cache outline-app
docker compose up -d outline-app
servers.jsonis in./data/on the host (mounted as a volume), so it survives container rebuilds anddocker compose downcycles.
# 1. Confirm .env is not in the image
docker run --rm outline-manager:latest cat /app/.env 2>&1
# Expected: No such file or directory
# 2. Confirm servers.json is not in the image
docker run --rm outline-manager:latest cat /app/data/servers.json 2>&1
# Expected: No such file or directory (it's a volume mount, not baked in)
# 3. Confirm env is injected at runtime
docker exec outline-app env | grep OUTLINE
# Expected: OUTLINE_API_URL=https://...
# 4. Check file permissions
ls -la /opt/outline-stack/app/.env
ls -la /opt/outline-stack/data/servers.json
# Both should show -rw------- (600)
# 5. Confirm data/ is owned by uid 1001
ls -la /opt/outline-stack/ | grep data
# Should show: drwx------ 2 1001 1001
# 6. Confirm read-only filesystem is enforced
docker exec outline-app touch /app/test 2>&1
# Expected: touch: /app/test: Read-only file system
# 7. Confirm Outline API port is blocked from public internet
ufw status | grep 39992
# Should show DENY or not appear (only 22/80/443 open)AUTH_ENABLED=true enables auth checks on all API routes. Traefik forwards requests through Authelia — after login, Authelia injects a Remote-User header which the middleware validates server-side.
AUTH_ENABLED=false skips all auth checks. Use this for local development only.
data/servers.jsonis written with mode0600and is never served over HTTP- The Outline API only stores cumulative bytes since server creation — there is no per-period usage without snapshotting
rejectUnauthorized: falseis set for Outline API connections since Outline uses a self-signed certificate by default- The
apiUrlis never returned to the browser under any circumstances — onlyid,name, andaddedAtare exposed to the client - Last Active and Peak Devices use the experimental Outline API (
/experimental/server/metrics?since=24h). These columns only appear if your server supports the endpoint. If unavailable, they are hidden automatically with no errors - The experimental API
sinceparameter accepts a duration string (e.g.24h) — Unix timestamps and ISO dates are not accepted
- Next.js — framework
- Tailwind CSS — styling
- Axios — API requests
- react-hot-toast — notifications
- qrcode.react — QR codes
- Traefik — reverse proxy + TLS
- Authelia — authentication portal