A self-hosted Flask app for organizing and launching your internal web services. Stores service URLs grouped by host with optional credentials, category tags, and auto-discovered favicons.
- Session-based login with first-run admin bootstrap
- Admin password change from the Settings page
- Login lockout after 5 failed attempts (5-minute cooldown, resets on restart)
- CSRF protection on all forms and AJAX requests
- Dashboard grouped by host with favicon auto-discovery
- Optional background health checks with per-app healthcheck endpoints
- Full-text search across name, URL, description, host, and category, with optional live (as-you-type) filtering
- Ctrl+K / Cmd+K to jump to the search box
- Filter by host or category
- Optional stored credentials (AES-encrypted at rest), copyable without revealing them
- Export and import the whole catalog as JSON, for backup or bulk-adding services
- Background image upload with MIME type validation and 10 MB size limit
- MySQL/MariaDB, PostgreSQL, or self-contained SQLite backend, with automatic schema creation and migration on startup
Each entry has a service type that controls how the dashboard renders it.
| Type | URL required | Favicon | Description |
|---|---|---|---|
| Web UI | Yes | Auto-discovered | A browser-accessible interface - Grafana, Portainer, Jellyfin, etc. |
| API | Yes | None | An HTTP API or backend service - REST APIs, internal endpoints, etc. |
- Python 3.12+
- MySQL/MariaDB (default), PostgreSQL — or use SQLite for self-contained deployments
# 1. Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Configure environment
cp .env.example .env
# Edit .env with your DB credentials and a strong SECRET_KEY
# 4. Create the database and user
mysql -u root -p <<'SQL'
CREATE DATABASE IF NOT EXISTS webui_manager
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'webui'@'localhost' IDENTIFIED BY 'changeme';
GRANT ALL PRIVILEGES ON webui_manager.* TO 'webui'@'localhost';
FLUSH PRIVILEGES;
SQL
# Then set DB_USER=webui, DB_PASSWORD=changeme (or your chosen values) in .env
# 5. Run
flask --app run.py runTables are created automatically on startup. Navigate to / and follow the admin setup prompt.
PostgreSQL 14+ works as a first-class backend. The same environment variables
are used as MySQL (DB_USER, DB_PASSWORD, DB_HOST, DB_NAME) — only the
port and backend switch:
# 1-3. Virtual env, dependencies, .env as in the MySQL setup above
# 4. Set the backend in .env
# DB_TYPE=postgres
# DB_PORT=5432
# DB_NAME=webui_manager
# DB_HOST=127.0.0.1
# (optionally DB_SSLMODE=require for an SSL-enabled server)
# 5. Create the database and user
psql -U postgres <<'SQL'
CREATE DATABASE webui_manager;
CREATE USER webui WITH PASSWORD 'changeme';
GRANT ALL PRIVILEGES ON DATABASE webui_manager TO webui;
SQL
# 6. Run
flask --app run.py runTables are created automatically on startup, and the startup auto-migration
handles schema sync the same way as on MySQL. Navigate to / and follow the
admin setup prompt.
Case sensitivity: PostgreSQL compares strings case-sensitively where MySQL's default collation does not. Usernames are matched case-insensitively on all backends (storage keeps the case you entered), so
admin,AdminandADMINall refer to the same account.
For single-user or simple deployments, set DB_TYPE=sqlite in your .env (or as an environment variable). The database file is stored at DB_PATH (default: data/webui_manager.sqlite).
# 1. Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Configure environment
cp .env.example .env
# Edit .env — set SECRET_KEY and ensure DB_TYPE=sqlite
# 4. Run (database file created automatically)
flask --app run.py runThe SQLite file is created automatically under data/webui_manager.sqlite on startup. Tables and schema are managed the same way as MySQL — auto-created and auto-migrated.
| Variable | Required | Description |
|---|---|---|
SECRET_KEY |
Yes | Flask session signing key |
DB_TYPE |
No | Database backend: mysql (default), sqlite, or postgres |
DB_USER |
Conditional | Database username (required when DB_TYPE=mysql or postgres) |
DB_PASSWORD |
Conditional | Database password (required when DB_TYPE=mysql or postgres) |
DB_HOST |
No | Database host (default: 127.0.0.1) |
DB_PORT |
No | Database port (default: 3306 for MySQL, 5432 for PostgreSQL) |
DB_NAME |
No | Database name (default: webui_manager) |
DB_SSLMODE |
No | PostgreSQL SSL mode (e.g. require; ignored by MySQL/SQLite) |
DB_PATH |
No | SQLite file path (default: data/webui_manager.sqlite) |
DATABASE_URL |
No | Full SQLAlchemy URL, overrides DB_TYPE and all DB_* fields (e.g. postgresql+psycopg://webui:password@127.0.0.1:5432/webui_manager) |
APP_CREDENTIALS_KEY |
No | Separate key for credential encryption (falls back to SECRET_KEY) |
AUTO_MIGRATE |
No | Sync existing tables to the current schema on startup (default: true). Missing tables are always created regardless |
The image is published at nullata/webui-manager.
docker pull nullata/webui-managerOr use it directly in your docker-compose.yml:
image: nullata/webui-managercp .env.example .env
# Edit .env - set SECRET_KEY, DB_PASSWORD, and any other valuesThe included docker-compose.yml builds the image locally. You will need an external MySQL/MariaDB instance reachable from the container; update DB_HOST in .env accordingly (or skip the external database entirely with SQLite — see 2d).
docker compose up --build -dTo use the pre-built Docker Hub image instead of building locally, edit docker-compose.yml and swap the build line for the two commented-out lines:
# build: . # remove or comment out
image: nullata/webui-manager:latest # uncomment
pull_policy: always # uncommentThen:
docker compose up -dIf you want Compose to manage the database as well, extend docker-compose.yml with a MariaDB service and update DB_HOST to match the service name:
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
MARIADB_DATABASE: ${DB_NAME:-webui_manager}
MARIADB_USER: ${DB_USER:-webui}
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
app:
image: nullata/webui-manager:latest
pull_policy: always
restart: unless-stopped
depends_on:
- db
ports:
- "${APP_PORT:-5000}:5000"
environment:
SECRET_KEY: ${SECRET_KEY}
APP_CREDENTIALS_KEY: ${APP_CREDENTIALS_KEY:-}
DB_HOST: db
DB_PORT: ${DB_PORT:-3306}
DB_USER: ${DB_USER:-webui}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME:-webui_manager}
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
volumes:
db_data:docker compose up -dBuild the image:
docker build -t webui-manager .Run the container (supply env vars inline or via --env-file):
docker run -d \
--name webui-manager \
--restart unless-stopped \
--env-file .env \
-p 5000:5000 \
webui-managerOr pull the pre-built image from Docker Hub:
docker run -d \
--name webui-manager \
--restart unless-stopped \
--env-file .env \
-p 5000:5000 \
nullata/webui-manager:latestNo external database needed. Mount a volume for the SQLite file so it survives container restarts:
docker run -d \
--name webui-manager \
--restart unless-stopped \
-p 5000:5000 \
-e SECRET_KEY=change-this-secret \
-e DB_TYPE=sqlite \
-e DB_PATH=/data/webui_manager.sqlite \
-v webui-data:/data \
nullata/webui-manager:latestOr with Compose: the included docker-compose.yml already contains the SQLite lines commented out — uncomment DB_TYPE, DB_PATH, and the two volumes sections. The result looks like this (the DB_* MySQL vars are ignored when DB_TYPE=sqlite, so they can stay or go):
services:
app:
build: . # or: image: nullata/webui-manager:latest
restart: unless-stopped
ports:
- "${APP_PORT:-5000}:5000"
environment:
SECRET_KEY: ${SECRET_KEY}
APP_CREDENTIALS_KEY: ${APP_CREDENTIALS_KEY:-}
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
DB_TYPE: sqlite
DB_PATH: /data/webui_manager.sqlite
volumes:
- db_data:/data
volumes:
db_data:docker compose up -ddocker-compose.postgres.yml layers a postgres:16 service (with a
persistent volume) onto the default compose file and points the app at it, so
the default docker compose up (MySQL) is untouched:
docker compose -f docker-compose.yml -f docker-compose.postgres.yml up --build -dSet DB_PORT, DB_NAME and any SSL mode in .env if they differ from the
defaults; DB_HOST and DB_TYPE are set by the override file.
Once the container is running, navigate to http://localhost:5000 (or your configured port). Tables are created automatically on startup - follow the on-screen admin setup prompt.
Schema migrations run automatically on startup (AUTO_MIGRATE, on by default): missing tables and columns are created, nullable and column-type changes are applied, and missing indexes, unique constraints, and MyISAM→InnoDB conversions are handled. Upgrading from any earlier version - including pre-service-type deployments - is just a matter of deploying the new version and restarting. Backing up your database before upgrading is still recommended.
The plain SQL scripts in the migrations/ directory are redundant with auto-migration and kept for reference; they are only needed if you run with AUTO_MIGRATE=false and want to apply schema changes by hand.
WebUI Manager can run on MySQL, PostgreSQL or SQLite — moving between any two of them is a data export/import, not a schema migration. The JSON export in Settings → Backup & Restore captures the catalog (services, hosts, categories, favicons), and import rebuilds it on the new backend.
Two things the catalog export does not carry, so plan for them:
- Users. Accounts are not in the export. On the fresh install, create the
admin via the first-run setup screen (
/prompts automatically on an empty DB) or from the CLI:flask --app run.py create-admin. - App settings (SMTP, dashboard options, healthcheck config) and healthcheck history are not exported either — re-enter settings on the new install.
Steps:
- Back up the old database first (a raw dump of MySQL/SQLite is fine — you are not moving it across engines, but keep it until you've confirmed the import).
- On the current install, export: Settings → Backup & Restore → Export. Tick "include passwords" — the export writes them as plaintext (the stored Fernet ciphertext is bound to this install's key and is never in the file), and a password-less export drops them entirely. They cannot be recovered afterwards. On import they are re-encrypted with the target install's key.
- Stand up the app against PostgreSQL (
DB_TYPE=postgres— see the setup section ordocker-compose.postgres.yml) and let it boot once: on the empty databasedb.create_all()builds the full schema. - Create the admin (see above), then import the exported JSON: Settings → Backup & Restore → Import. Services, hosts, categories and favicons are recreated.
The database itself is never copied byte-for-byte between engines — the schema is rebuilt from the models and the catalog flows through JSON.
Large installs: the case-insensitive username lookup (
lower(username)) can't use the plain unique index on PostgreSQL. That's a non-issue at homelab scale, but if an install ever grows past a handful of users, add a functional index:CREATE INDEX ON "user" (lower(username));
# Test suite (pytest + Playwright) - see tests/README.md
pip install -r requirements-dev.txt
playwright install chromium # one-off, for the browser tests
pytest # everything
pytest -m "not browser" # API and database only, no browser needed
# Rebuild the CSS after editing templates, JS, or tailwind-src.css.
# Output (app/static/css/tailwind.css) is committed, so rebuild before pushing.
./build-tailwind.shTests run against a temporary SQLite database and never touch your .env or
real database. There is no JavaScript build step - app/static/js/app.js is
plain vanilla JS loaded directly.
Font Awesome Free 7.1.0 is bundled under CC BY 4.0 (icons), SIL OFL 1.1 (fonts), and MIT (code). See app/static/fontawesome-free-7.1.0-web/LICENSE.txt.
IBM Plex Sans and Space Grotesk are bundled under the SIL Open Font License 1.1. See app/static/fonts/IBM_Plex_Sans/OFL.txt and app/static/fonts/Space_Grotesk/OFL.txt.
The UI was generated with Gemini 2.5 and refined with many manual and agentic adjustments.
Licensed under the Apache License 2.0.

