Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/AZhur771/pivpn-web/llms.txt

Use this file to discover all available pages before exploring further.

PiVPN Web maintains its own user store in a SQLite database managed by TypeORM. Passwords are hashed with bcrypt (cost factor 10) before they are written to the database, so plaintext credentials are never persisted. User accounts are seeded automatically the first time the application starts, using the credentials you supply via environment variables. No manual database setup is required.

User roles

PiVPN Web supports three distinct roles. Each role is controlled by a pair of environment variables — a username and a password.

Admin

The admin account has full access to the dashboard. An admin can:
  • List, create, and delete WireGuard clients
  • Enable and disable individual clients
  • View QR codes and download client configuration files
Set via ADMIN_USER and ADMIN_PASSWORD. The admin account is stored with admin: true in the database.

Viewer

The viewer account has read-only access. A viewer can browse the client list and inspect connection status but cannot make any changes. This is useful for giving monitoring access to a team member without granting administrative privileges. Set via VIEWER_USER and VIEWER_PASSWORD. The viewer account is stored with admin: false in the database.

Tech

The tech account has admin-level access (stored with admin: true) and is intended for automation, scripts, or integrations that need programmatic access to the API. It is seeded by a separate migration so that it can be omitted independently of the viewer account. Set via TECH_USER and TECH_PASSWORD.

Environment variable reference

RoleUsername variablePassword variableadmin flag
AdminADMIN_USERADMIN_PASSWORDtrue
ViewerVIEWER_USERVIEWER_PASSWORDfalse
TechTECH_USERTECH_PASSWORDtrue
If VIEWER_USER / VIEWER_PASSWORD or TECH_USER / TECH_PASSWORD are not set when the container first starts, those accounts are not created. The migrations insert rows only for the variables that are present at migration time.

User entity structure

Each account is stored as a row in the user table with the following columns, defined in lib/entities/User.ts:
@Entity()
export class User {
  @PrimaryColumn()
  id!: string;           // UUID v4, generated at migration time

  @Index({ unique: true })
  @Column()
  login!: string;        // The username (from env var)

  @Column()
  password!: string;     // bcrypt hash of the password

  @Column()
  admin!: boolean;       // true = admin/tech, false = viewer
}

How accounts are seeded

Accounts are created by TypeORM migrations that run once on first startup:
  • AddUsers migration — inserts the admin and viewer accounts using ADMIN_USER, ADMIN_PASSWORD, VIEWER_USER, and VIEWER_PASSWORD.
  • AddTechUsers migration — inserts the tech account using TECH_USER and TECH_PASSWORD.
Each migration hashes the password with bcrypt.genSalt(10) before inserting the row, so the plaintext value from the environment variable is never stored.
// Simplified from lib/migrations/1741016929954-AddUsers.ts
const salt = await bcrypt.genSalt(10);
const adminPasswordHash = await bcrypt.hash(ADMIN_PASSWORD ?? '', salt);

await queryRunner.query(
  `INSERT INTO "user" (id, login, password, admin) VALUES (?, ?, ?, ?)`,
  [uuidv4(), ADMIN_USER, adminPasswordHash, true],
);
Because TypeORM tracks which migrations have already run, these INSERT statements execute exactly once. Restarting the container does not re-seed the database.

Changing passwords

Changing the ADMIN_PASSWORD (or any other password) environment variable after the first run does not update the stored bcrypt hash. The migration has already run and will not execute again. The new env var value is ignored.
To change a password after initial setup, choose one of the following approaches: Option 1 — Delete the database and re-seed (simplest) Stop the container, delete the SQLite database file, update the environment variable, and restart. The migrations will run again against a fresh database.
# If you mounted a volume, remove the database file from the host path
rm /path/to/data/pivpn.sqlite

# Restart with the new password env var
docker compose up -d
Option 2 — Update the database directly (advanced) Generate a new bcrypt hash and update the row directly in the SQLite database. This preserves all other data (sessions, banned clients, etc.).
# Enter the running container
docker exec -it pivpn-web sh

# Generate a hash (requires Node.js available inside the container)
node -e "const b = require('bcryptjs'); b.hash('newpassword', 10).then(h => console.log(h))"

# Open the database with sqlite3 and update the row
sqlite3 /app/pivpn.sqlite \
  "UPDATE user SET password = '<hash>' WHERE login = 'admin';"

Build docs developers (and LLMs) love