We are focused on developing custom software solutions for different purposes.
This template is the result of the learning curve we had developing many applications.
We want to share it with the community - to help NiceGUI becomming bigger. A big thank you to @zauberzeug/niceGUI for this amazing framework.
A full-stack, batteries-included starter template for building web applications with NiceGUI in pure Python.
NiceStack exists so you don't wire the same plumbing together on every new project. It ships the parts almost every production app needs — already built, tested and connected — so you can delete the demo, keep the foundation, and start building your own app.
Most stacks force a split brain: a JavaScript framework on the front end, a separate Python service on the back end, and a pile of glue code, build tooling and duplicated models in between. NiceStack keeps the whole stack in one Python codebase, borrowing the ideas that make those ecosystems pleasant to work in while dropping the boilerplate that makes them slow to start:
- Reusable components. Self-contained UI building blocks in
components/(dialogs, dropzone, notifications, password form, …) — compose them across pages instead of copy-pasting markup. - Layered like an MVC app.
models/holds framework-free domain data,services/holds business logic,views/render pages,core/provides infrastructure. Views stay thin and delegate every rule to the service layer. - Pure Python, end to end. No JS build step, no hand-written REST layer, no client/server model duplication. One language, one mental model, one place to look.
| Area | What you get |
|---|---|
| Auth & sessions | Session-based auth with idle (30 min) and absolute (12 h) timeouts, forced password change on first login |
| Access control | Role-based access (admin / user), bootstrap admin account protected from deletion/demotion |
| Audit log | Persistent activity trail (who did what, when, from which IP) with an admin-only review page |
| Hardening | Login rate limiting with account lockout (per IP and per username), PBKDF2-HMAC-SHA256 hashing, hardened response headers |
| Persistence | SQLAlchemy 2.0 ORM over SQLite in WAL mode, additive schema migrations |
| i18n | Live language switching (English / German), no page reload |
| UI | Responsive shell with mobile bottom nav, token-based design system with a live component reference page |
| PWA | Installable web app: web manifest, service worker and offline app shell |
| Ops | Rolling daily file logging with a runtime-adjustable level, pytest suite via the NiceGUI testing plugin |
Authentication, hashing and rate limiting are built on the Python standard library and NiceGUI/Starlette primitives — no external auth service required.
- Python 3.11+
- uv
[https://docs.astral.sh/uv/getting-started/installation/#standalone-installer]
# from the project root
cd app
uv run main.pyuv resolves dependencies from pyproject.toml, creates a virtual environment on first run, and starts the app.
Open http://localhost:8080 and sign in with the seeded administrator account:
| Username | Password |
|---|---|
admin |
admin |
You'll be required to set a new password before reaching any other page. The default admin account is created automatically the first time the database is initialized.
App metadata and the listen port live in config.json:
{
"appName": "Production Suite",
"appVersion": "Beta 1.0",
"appPort": 8080
}The session-signing secret is resolved by _get_storage_secret() in main.py:
- the
STORAGE_SECRETenvironment variable, if set; - otherwise a strong random secret is generated once and persisted (
0600permissions) atdata/.storage_secret, so signed cookies survive restarts.
Always set
STORAGE_SECRETexplicitly in production.
- Set your app name, version and port in
config.json. - Add a page: create
views/<name>_content.pywith acontent()function, register a route in theui.sub_pagesmap inmain.py, and add a sidebar entry inheader.py. - Add reusable UI in
components/, domain types inmodels/, business logic inservices/— keep views thin. - Extend the data model in
core/database.pyand its service. - Add translations in
services/i18n.py; strings flow throught('your.key'). - Remove the demo views you don't need (orders, pallets, packing, print demo, design system, icons) and keep the shell and core.
main.py Entry point: page routing, layout decorator, run targets
header.py Application shell: header bar, sidebar, mobile navigation
config.json App name, version and port
core/
database.py SQLAlchemy engine, ORM models, sessions, schema init, seeding
security.py Password hashing, rate limiting, auth/session middlewares
logging_config.py Rolling file + console logging, runtime log level
models/
enums.py Domain enumerations (UserRole)
user.py User data model (plain, dependency-free)
audit.py Audit entry model and AuditAction enum (dependency-free)
services/
user_service.py User persistence and business rules, password policy
audit_service.py Audit trail persistence and queries, audit_record() helper
i18n.py Translation catalog and live language switching
dashboard_data.py Sample data for the dashboard view
views/
*_content.py One module per page (dashboard, shipping, users, ...)
login_content.py Login page and rate-limited login flow
change_password_content.py Forced/self-service password change
audit_content.py Admin-only audit log review page
_common.py Shared view helpers
components/
dialogs.py, dropzone.py, notifications.py, password_form.py,
print_component.py, sidebar_hint.py Reusable UI building blocks
tests/
conftest.py Fixtures: NiceGUI testing plugin, isolated temp database
test_security.py, test_user_service.py, test_audit_service.py, test_ui_smoke.py
assets/ CSS (design tokens, components), fonts, images, icons, PWA files
certs/ Self-signed cert.pem / key.pem for local HTTPS
data/ SQLite database and generated secret (created at runtime)
backup/ Timestamped database backups (created at runtime)
logs/ Rolling application logs (created at runtime)
A single top-level page (/) is wrapped by the with_base_layout decorator, which applies the theme, global stylesheet and application shell from header.py. Inside that shell, ui.sub_pages maps client-side routes (/shipping, /orders, /users, …) to their view handlers, so navigation happens without a full page reload. A standalone /print/{data} route renders outside the shell for print output.
Most views are rendered through a small _localized helper that wraps them in a ui.refreshable, so the whole page rebuilds in place when the language changes.
| Layer | Responsibility |
|---|---|
Views (views/) |
One content() function per page. Orchestrate layout, delegate all persistence and business logic to services — never talk to the database directly. |
Components (components/) |
Reusable, self-contained UI building blocks (dialogs, dropzone, toasts, password form, print helper, sidebar hints). Pure presentation. |
Models (models/) |
Framework-free data structures. User is a frozen dataclass, UserRole is the RBAC enum — no database or security dependencies, so both are trivial to unit-test in isolation. |
Services (services/) |
Business logic. UserService wraps an open database session and implements account rules (creation, credential verification, password policy, role/activation changes). AuditService records and queries the activity trail. i18n owns the translation catalog and language state. |
Core (core/) |
Infrastructure: database session/context manager, security layer (hashing, rate limiting, middlewares), logging configuration. |
Security is centralized in core/security.py and applied globally through two Starlette middlewares. Middlewares run in reverse registration order, so SecurityHeadersMiddleware wraps the outside of every response while AuthMiddleware runs closest to the application:
app.add_middleware(security.AuthMiddleware) # inner: runs first on the way in
app.add_middleware(security.SecurityHeadersMiddleware) # outer: runs last, decorates the responseEvery request is checked, in order:
- Internal fast-path — NiceGUI's own WebSocket/SSE traffic (
/_nicegui,/_starlette) passes through untouched, so realtime updates never hit session storage. - Public allow-list —
/login,/health,favicon.ico,manifest.json,sw.jsand the static asset prefixes (/assets/css,/assets/images,/assets/icons,/assets/fonts) are reachable without a session. - Session timeouts — a 30-minute sliding idle timeout and a 12-hour absolute cap since login are enforced on every request. On expiry the session is cleared and the request redirected to
/login?reason=timeout|expired(or401under/api/*). - Forced password change — a session with
must_change_passwordset is confined to/change-password(403under/api/*). - Authentication gate — any remaining unauthenticated request to a protected route redirects to
/login?redirect_to=<path>(or401under/api/*).
Browser navigation gets redirects; requests under /api/* get JSON errors — the same middleware serves both page and programmatic clients. Adding a route to the public set is a one-line change to unrestricted_page_routes / _PUBLIC_ASSET_PREFIXES.
Session state lives in NiceGUI's app.storage.user, backed by a cookie signed with storage_secret. On login the store holds the user id, username, role, language and the login_at / last_activity timestamps that drive the timeouts. logout() clears the store and redirects to /login. current_user() re-reads the user from the database on every call, so a role or activation change takes effect on the user's next request without re-login. The login handler rejects open-redirect attempts by requiring redirect_to to be a same-site path.
UserRole (models/enums.py) defines admin and user. User.is_admin is the single check that gates admin-only areas: the Users and App Settings entries are hidden from the sidebar and burger menu for non-admins, and admin views enforce the same check server-side through UserService. The bootstrap admin account can never be deleted, deactivated or demoted, and the service refuses to remove or demote the last remaining admin.
Passwords are stored as pbkdf2_sha256$iterations$salt$hash using PBKDF2-HMAC-SHA256 with 260,000 iterations and a per-password 16-byte salt — standard library only, no external crypto dependency. Verification uses hmac.compare_digest for constant-time comparison. A login attempt for an unknown username still runs a dummy hash comparison, so response timing can't be used to enumerate valid accounts.
New and reset accounts start with a default password and must_change_password set; the middleware confines them to /change-password. The policy — length 10–64, at least one upper, lower, digit and special character — is defined once in services/user_service.py and shared by both server-side validation and the live requirement checklist in the PasswordChangeForm component, so the UI and the server can never disagree.
An in-memory sliding-window limiter tracks attempts per key over a 15-minute window; after 5 failed attempts the key is locked out for 30 minutes. The login flow rate-limits on two keys simultaneously — client IP and the targeted username (lowercased) — so neither a single source nor a single account can be brute-forced. The client IP is read from X-Forwarded-For when present (put a trusted proxy in front in production). Stale entries are pruned periodically to bound memory use.
Note: the limiter is process-local. For multi-worker or multi-instance deployments, back it with a shared store such as Redis.
SecurityHeadersMiddleware sets the following on every response via setdefault, so an explicitly set header is never overwritten:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
X-XSS-Protection |
0 (disables the legacy, exploitable auditor) |
Permissions-Policy |
camera=(self), microphone=(), geolocation=() |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
As an app grows past a single trusted user, "who changed this?" and "who signed in?" stop being answerable from memory. Application logs help, but they are optimized for debugging, rotate away, and aren't something a non-technical administrator can read. The audit log is the accountability layer on top: a durable, human-readable record of the security- and data-relevant actions taken through the app.
Why it's needed
- Accountability — every sensitive action is attributed to a specific user, so nothing happens anonymously.
- Incident response — after a compromise or mistake you can reconstruct exactly what happened, when, and from which IP.
- Compliance — many frameworks (ISO 27001, SOC 2, GDPR accountability) expect an access/activity trail for accounts and personal data.
- Deterrence — visible logging discourages misuse by insiders who know their actions are recorded.
What gets recorded
Each entry stores a UTC timestamp, the acting user, the action, an optional target/detail, and the client IP. The following actions are captured out of the box:
| Category | Actions |
|---|---|
| Authentication | successful sign-in, failed sign-in (including attempts on disabled accounts), sign-out |
| Account security | password change (forced and self-service), admin password reset |
| User management | user created, deleted, role changed, activated, deactivated |
| Profile | profile updated, language changed |
How it works
The trail is persisted in a dedicated audit_log table (core/database.py) and served through services/audit_service.py, mirroring the service pattern used for users. Call sites record an event with a single line via the module-level audit_record(...) helper, which resolves the acting user from the session and is written so that a logging failure can never break the action it is recording. Administrators review the trail on the Audit Log page (/audit), which lists recent activity with per-action filtering and a clear-log action; the page and route are admin-only.
Adding a new audited action is a two-step change: add a value to AuditAction in models/audit.py, then call audit_record(AuditAction.YOUR_ACTION, target=..., details=...) at the relevant call site.
The audit log records events, not full before/after snapshots. For point-in-time recovery of the data itself, use the database backups described below.
Retention and integrity. The audit_log table grows without bound — unlike the rolling application logs, entries are never rotated or expired automatically. For a long-running or high-traffic deployment, periodically archive and prune it (e.g. export rows older than N days, then delete them). The trail is not tamper-proof: it lives in the same database as the rest of the app, and an administrator can clear it from the Audit Log page, so treat it as an accountability aid rather than forensic evidence. If you need stronger guarantees, ship the entries to an append-only external sink (syslog, a log service, or a write-only table on a separate database).
Persistence uses the SQLAlchemy 2.0 ORM over a single SQLite file at data/app.db. The users table is mapped by UserRecord; the service layer converts ORM rows to the framework-free User dataclass before returning them to views, so no ORM objects leak out of an open session.
get_session() yields a SQLAlchemy Session, commits on success, rolls back on error, and always closes. Each connection is configured via a connect-event listener with:
journal_mode = WAL— concurrent readers during a writesynchronous = NORMAL— the safe, fast companion to WALbusy_timeout = 5000— wait on locks instead of failingforeign_keys = ON
init_db() creates the schema with Base.metadata.create_all, applies additive column migrations for databases created by earlier versions, and seeds the default admin on an empty database.
Migrations: the built-in migrator only adds columns. Breaking changes (dropping or renaming columns, changing types) are out of scope for
create_all— if your project needs a richer schema history, wire in Alembic and manage migrations there instead.
Scaling beyond SQLite: SQLite is a great default and handles this template comfortably, but it serializes writes (one writer at a time, even under WAL). For low-concurrency and read-heavy workloads that's fine; once you expect roughly 25+ concurrent active users or write-heavy, multi-worker deployments, move to a client/server database such as PostgreSQL. Because all access goes through the SQLAlchemy ORM and
get_session(), switching is mostly a matter of changing the engine URL (and the SQLite-specificPRAGMAconnect listener) incore/database.py.
core/database.py provides two maintenance helpers:
backup_database(label=None)writes a consistent snapshot of the database into thebackup/folder asapp-<timestamp>.db. It uses SQLite's online backup API, so it is safe to run while the app is live, including under WAL. Returns the backup path.reset_admin_to_default()restores theadminaccount to an active admin with the default password (admin/admin) and the forced-password-change flag, recreating it if it was deleted. It always takes a safety backup first and returns that backup path.
Both are plain functions, so they can be called from a maintenance script, a shell, or wired to a button on the admin settings page:
uv run python -c "from core.database import backup_database; print(backup_database())"
uv run python -c "from core.database import reset_admin_to_default; print(reset_admin_to_default())"Backups are local by design and are git-ignored (only backup/.gitkeep is tracked).
Translations live in services/i18n.py as a key -> {lang: text} catalog. Every translatable element is bound to a per-client language state, so switching the language updates the whole UI live, without a reload. The choice is persisted to the session and to the account row.
Shipped languages: English (en), German (de). Missing translations fall back to English, then to the key itself. To add a language, extend LANGUAGES and provide the corresponding catalog entries.
The look and feel is driven by a single stylesheet, assets/css/global-css.css, built around CSS custom properties rather than ad-hoc styles:
- Tokens — colors, spacing, radii, typography scale and a fluid base font size, declared once under
:root(--primary,--danger,--radius,--text-lg, …). Restyle the app by editing tokens in one place. - Component and utility classes — semantic classes (
.button,.card,.badge,.alert,.chip,.data-table,.dialog-card, …) and small utilities (.text-sm,.mt-4,.flex,.gap-2) applied through NiceGUI's.classes(...). - Quasar overrides — the underlying Quasar widgets (inputs, tabs, steppers, progress bars, pickers) are normalized to match the tokens.
The stylesheet is served as a static file with an mtime cache-buster, so CSS edits only need a browser refresh, not a server restart. The Design System page (/design-system) renders every token, component and utility live, next to its class name.
| Component | Purpose |
|---|---|
notifications.py |
Styled HTML toasts. notify(message, type=...) for one-shot toasts (positive / negative / warning / info); notify_ongoing(...) for a spinner toast with a dismiss() handle for long tasks. |
dialogs.py |
confirm_dialog(...) and info_dialog(...) awaitable helpers, styled to the design system. |
password_form.py |
PasswordChangeForm with a live requirement checklist, shared by the forced change-password page and account settings. |
dropzone.py |
Drag-and-drop upload zone wrapping ui.upload; drop or click to select, entirely in Python. |
print_component.py |
Print-optimized document renderer for the standalone /print/{data} route (no shell). |
sidebar_hint.py |
Tooltip revealing a nav item's label when the sidebar is collapsed. |
The app is installable and works offline for its shell. The pieces are wired in main.py:
- Web manifest — served at
/manifest.json, generated fromconfig.jsonso the name, theme and icons stay in sync with the app. Declaresdisplay: standalone, start URL, theme/background colors and icons (anyandmaskable). - Service worker —
assets/pwa/sw.js, served at/sw.jsfrom the origin root (withService-Worker-Allowed: /) so its scope covers the whole app. Static assets under/assets/use stale-while-revalidate; page navigations are network-first with an offline fallback; NiceGUI's realtime channels and API routes are never intercepted. - Head tags — manifest link,
theme-color, Apple touch icon and mobile web-app meta tags are injected globally, plus a small script that registers the service worker on load.
/manifest.json and /sw.js are allow-listed in core/security.py, so they load before authentication and the app is installable from the login screen.
Icons live in assets/icons/ and are referenced by the manifest (pwa_manifest in main.py) and the Apple touch-icon head tag. Replace these PNG files, keeping the exact sizes:
| File | Size (px) | Purpose |
|---|---|---|
icon-192.png |
192x192 | Standard icon (required) |
icon-512.png |
512x512 | Standard icon, install/splash (required) |
icon-maskable-512.png |
512x512 | Maskable icon: keep the logo inside the centered ~80% safe zone (~10% padding each side) so Android can crop it to any shape |
apple-touch-icon.png |
180x180 | iOS home-screen icon (no transparency; use a solid background) |
Use square, transparent PNGs for the standard icons and a padded version for the maskable one. If you only supply your own logo, you can regenerate the whole set from a single source image with Pillow (no need to add it as a project dependency):
uv run --with pillow python -c "from pathlib import Path; from PIL import Image; s=Image.open('assets/images/logo.png').convert('RGBA'); o=Path('assets/icons'); o.mkdir(parents=True, exist_ok=True); [ (lambda c,r: (c.paste(s.resize((int(s.width*r),int(s.height*r)),Image.LANCZOS),((n-int(s.width*r))//2,(n-int(s.height*r))//2),s.resize((int(s.width*r),int(s.height*r)),Image.LANCZOS)), c.save(o/f) if not white else c.convert('RGB').save(o/f)))(Image.new('RGBA',(n,n),(255,255,255,255) if white else (0,0,0,0)), min(int(n*sc)/s.width,int(n*sc)/s.height)) for n,sc,white,f in [(192,1.0,False,'icon-192.png'),(512,1.0,False,'icon-512.png'),(512,0.6,True,'icon-maskable-512.png'),(180,0.85,True,'apple-touch-icon.png')] ]"The favicon.ico used by the browser tab is set separately via favicon= in the ui.run(...) call in main.py.
core/logging_config.py configures a timed rotating file handler (logs/app.log, rotated daily) plus a console handler. The active level (DEBUG–CRITICAL) is persisted to logs/.log_level and can be changed live by an admin from App Settings, applying across the whole app without a restart. Setup is idempotent, so it's safe under the dev auto-reloader, and the noisy watchfiles reload logger is quieted so its own change events don't feed back into the log.
uv run pytestTests use the NiceGUI testing plugin (nicegui.testing.user_plugin) for UI simulation without a browser. The temp_db fixture points the database module at a throwaway SQLite file so tests never touch data/app.db. asyncio_mode = "auto" and testpaths = ["tests"] are set in pyproject.toml.
main.py defines three run targets at the bottom of the file — enable exactly one:
- Development (default) — auto-reload on file changes.
- Production — bound to
0.0.0.0,reload=False. - Production over HTTPS —
reload=Falseplus TLS using the certificates incerts/.
# Production over HTTPS
ui.run(root, host='0.0.0.0', storage_secret=_get_storage_secret(), title=appName,
port=appPort, favicon='favicon.ico', reconnect_timeout=20, reload=False,
ssl_certfile='certs/cert.pem', ssl_keyfile='certs/key.pem')The bundled certs/cert.pem and certs/key.pem are self-signed and intended for local testing only. Replace them with certificates from a trusted authority for real deployments, and always set STORAGE_SECRET in the environment.
The repository root ships a Dockerfile and a docker-compose.yaml that build the app into an image and run it, mounting ./app into the container so the database (app/data/), logs (app/logs/) and backups (app/backup/) persist on the host.
ui.run(root, storage_secret=_get_storage_secret(),
host=os.environ['HOST'], title=appName, port=appPort,
favicon='favicon.ico', reconnect_timeout=20, reload=False)-
For Docker adjust
main.pyand use the container run target. The storage secret is resolved by_get_storage_secret()(which readsSTORAGE_SECRETfrom the environment, set indocker-compose.yaml), andHOSTcomes from the environment too:# For Docker ui.run(root, storage_secret=_get_storage_secret(), host=os.environ['HOST'])
Go one folder back in the terminal where the docker-compose.yaml is located and start the stack:
cd .. docker compose up
Your container builds the image template:latest and runs on http://localhost:8080.
If you don't set
STORAGE_SECRET(in your shell or a.envfile),_get_storage_secret()generates a strong random secret and persists it atapp/data/.storage_secret, which survives restarts via the./app:/appmount. Set it explicitly to share one secret across multiple instances.
A standalone build can be produced with PyInstaller (declared as a dependency):
uv run python -m PyInstaller --name 'ProductionSuite' --onedir main.py \
--add-data '<path-to>/nicegui;nicegui' --noconfirm --clean- Python 3.11+
- NiceGUI 3.9 (with Highcharts extra)
- SQLAlchemy 2.0 ORM over SQLite
- pytest / pytest-asyncio
- uv for dependency and environment management
Issues and pull requests are welcome. For anything non-trivial, please open an issue first to discuss the approach before investing time in an implementation.

