Skip to content

chitminthu/outline-manager-web

Repository files navigation

Outline Dashboard

A self-hosted web UI for managing one or more Outline VPN servers. Built with Next.js.

Why

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.

Features

  • 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

Screenshots

Outline Dashboard — Mockup

How It Works

Single server (simplest path)

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.

Multiple servers

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.

Page structure

/                  → Dashboard — all servers, live status, add/remove
/server/[id]       → Detail page — keys, usage, limits, rename

Security

servers.json protection

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

API tokens

  • OUTLINE_API_URL lives in .env (server-side only). Never use NEXT_PUBLIC_ prefix.
  • .env is excluded from the Docker image via .dockerignore.
  • Secrets are injected at container runtime via env_file in docker-compose.yml.

Requirements

  • 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 apiUrl value. It looks like https://1.2.3.4:39992/sometoken.


Local Development

git clone https://github.com/chitminthu/outline-manager-web
cd outline-manager-web
npm install
npm install qrcode.react

Create .env:

OUTLINE_API_URL=https://your-server-ip:port/your-api-prefix
AUTH_ENABLED=false

Make sure data/ is in your .gitignore:

data/

Run:

npm run dev

Open http://localhost:3000.

data/servers.json is created automatically on first run. To add more servers, click + Add Server on the dashboard.


Production Deployment

Step 1 — Server setup

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 enable

Step 2 — Directory layout

mkdir -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

Step 3 — Clone the app

git clone https://github.com/chitminthu/outline-manager-web /opt/outline-stack/app

Step 4 — App environment

cat > /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/.env

Step 5 — Dockerfile

app/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;

Step 6 — Traefik config

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: web

traefik/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-Email
touch /opt/outline-stack/traefik/certs/acme.json
chmod 600 /opt/outline-stack/traefik/certs/acme.json

Step 7 — Authelia config

authelia/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.txt

Generate 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/*

Step 8 — docker-compose.yml

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"

Step 9 — Data folder permissions

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.json

Step 10 — Deploy

docker network create proxy
cd /opt/outline-stack
docker compose build --no-cache outline-app
docker compose up -d
docker compose logs -f

Update after a git push

cd /opt/outline-stack/app && git pull
cd /opt/outline-stack
docker compose build --no-cache outline-app
docker compose up -d outline-app

servers.json is in ./data/ on the host (mounted as a volume), so it survives container rebuilds and docker compose down cycles.

Security check after deploy

# 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)

Authentication

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.


Notes

  • data/servers.json is written with mode 0600 and is never served over HTTP
  • The Outline API only stores cumulative bytes since server creation — there is no per-period usage without snapshotting
  • rejectUnauthorized: false is set for Outline API connections since Outline uses a self-signed certificate by default
  • The apiUrl is never returned to the browser under any circumstances — only id, name, and addedAt are 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 since parameter accepts a duration string (e.g. 24h) — Unix timestamps and ISO dates are not accepted

Stack

About

Web-based dashboard for managing Outline VPN servers. Built with Next.js.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors