diurnal, / daɪˈɜr nl /, adjective
"of or relating to a day or each day; daily"
- Introduction
- Features
- Screenshots
- Deployment
- Environment Variables
- User Settings
- Administrator User
- REST API
- Versioning
- License
Diurnal is a small, self-hosted web application for tracking daily habits. You define actions (the things you want to do or avoid each day) and log them as you go. Diurnal keeps a running calendar of everything you've logged and turns that history into meaningful statistics: current and longest streaks, weekly averages, month-over-month trends, and more.
- User-defined Actions: Define any habit you want to track, each with its own name and colour
- Daily logging: Increment an action once, or set an exact count for any day
- Calendar views: Your whole history on a calendar, with a choice of view
- Full: Cell-based calendar, with event text per action
- Minimal: A coloured dot per action
- Stacked: Horizontal bars per action
- Statistics: Per-action stats including:
- Current streak
- Longest streak
- Biggest gap
- Total unique days
- Total count
- Weekly average
- Best month/year
- Comparisons to last month/year
- Theming: Light and dark modes available
- Mobile view: Styled for both web browser and mobile usage
- User Management: User accounts & roles can be managed by administrators
- OIDC: Can be integrated with an external identity provider (Authelia, Keycloak, etc.)
Expand the section below to view screenshots.
Click to view screenshots
Dashboard (dark)![]() |
Dashboard (light)![]() |
Minimal Calendar![]() |
Stacked Calendar![]() |
Actions![]() |
Stats![]() |
Administrator![]() |
Settings![]() |
Diurnal is distributed as a Docker image (zodac/diurnal) and is intended to be run with Docker Compose
alongside a PostgreSQL container.
1. Get the Docker Compose file:
Download docker-compose.example.yml from this repository and save it as docker-compose.yml:
curl -o docker-compose.yml https://raw.githubusercontent.com/zodac/diurnal/master/docs/docker-compose.example.yml2. Set your secrets:
Edit docker-compose.yml and change the required value (in both the diurnal and diurnal-db services where noted):
DB_PASSWORD: a strong PostgreSQL password
A quick way to generate a good value:
openssl rand -base64 323. Start the application:
docker compose up -dDiurnal will be available at http://localhost:8080. The database schema is created automatically on first start.
4. Create your account:
Open the app and register. The first account created becomes the administrator. Even if using OIDC_ENABLED, this account is created locally.
It may later be linked to your OIDC provider, though I would suggest keeping it as a super-user in case of any IdP issues.
Diurnal is configured entirely through environment variables on the diurnal container. Only DB_PASSWORD is required; everything else has a
sensible default.
| Variable | Description |
|---|---|
DB_PASSWORD |
PostgreSQL password (must match the password on the database container) |
| Variable | Default | Description |
|---|---|---|
DB_HOST |
diurnal-db |
Hostname of the PostgreSQL server |
DB_PORT |
5432 |
PostgreSQL port |
DB_NAME |
diurnal_db |
Database name |
DB_USER |
diurnal_user |
Database user |
| Variable | Default | Description |
|---|---|---|
TZ |
UTC |
IANA timezone (e.g. Europe/London) used for day boundaries |
LOG_LEVEL |
INFO |
One of TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF |
DB_LOG_LEVEL |
WARN |
Set to TRACE to log every SQL statement + bound parameters (verbose; may expose parameter values) |
Diurnal supports two sign-in methods (local password accounts and OIDC) which can run separately or together; at least one must be enabled or the app refuses to start. Regardless of how it is configured, the first account is always created locally through the setup page.
| Variable | Default | Description |
|---|---|---|
PASSWORD_AUTH_ENABLED |
true |
Set to false to disable password login entirely (requires OIDC to be enabled) |
PASSWORD_AUTH_UNIFORM_TIMING |
true |
Keep login response time constant whether or not the email exists, so accounts can't be enumerated by timing |
ENABLE_REGISTRATION |
true |
Set to false to close the /register page |
Please note that if PASSWORD_AUTH_ENABLED is changed to false, previously password-only accounts will be converted to OIDC accounts upon login.
If both PASSWORD_AUTH_ENABLED and OIDC_ENABLED are true, this auto-conversion is not done. Users must explicitly link to an OIDC account in
the user settings page.
Passwords are stored as Argon2id hashes. The three cost parameters below were chosen so a single hash takes roughly 100–500 ms on my hardware. For resource-constrained hardware, you may need to tune these values.
| Variable | Default | Description |
|---|---|---|
PASSWORD_HASH_ARGON2_MEMORY_KIB |
98304 |
Memory cost in KiB (96 MiB) |
PASSWORD_HASH_ARGON2_ITERATIONS |
3 |
Number of passes for hashing |
PASSWORD_HASH_ARGON2_PARALLELISM |
4 |
Number of lanes; cuts latency at the cost of more cores |
OIDC is disabled by default. When enabled, users can sign in through your identity provider alongside (or instead of) password login. Register
{your-base-url}/oidc-callback as the redirect URI with your IdP.
| Variable | Default | Description |
|---|---|---|
OIDC_ENABLED |
false |
Set to true to activate OIDC |
OIDC_ISSUER_URL |
Base URL of the OIDC provider (e.g. https://auth.example.com) |
|
OIDC_CLIENT_ID |
diurnal |
Client ID registered with the provider |
OIDC_CLIENT_SECRET |
Client secret for the registered client | |
OIDC_PROVIDER_NAME |
your identity provider |
Name shown on the login button ("Log in with your identity provider") |
OIDC_AUTO_REDIRECT |
false |
If true, /login redirects straight to the provider |
OIDC_SCOPES |
email,profile,groups |
Extra scopes requested with openid (use email,profile for Google) |
OIDC_PKCE_ENABLED |
true |
PKCE on the code flow; disable only if the provider rejects it |
OIDC_ADMIN_GROUP |
IdP group whose members are granted the Administrator role |
|
OIDC_USER_GROUP |
IdP group whose members are granted the User role |
|
OIDC_LOGOUT_URL |
OIDC users are redirected here after logging out |
Authelia example
Add a client to your Authelia configuration.yml:
identity_providers:
oidc:
authorization_policies:
diurnal_auth_policy:
default_policy: 'deny'
rules:
- policy: 'one_factor'
subject:
- [ "group:diurnal_admins" ]
- [ "group:diurnal_users" ]
claims_policies:
diurnal_claim_policy:
id_token: [
'alt_emails',
'email',
'email_verified',
'groups',
'name',
'preferred_username'
]
clients:
- client_name: Diurnal OIDC Client
client_id: 'Diurnal'
client_secret: '<hash of OIDC_CLIENT_SECRET>'
authorization_policy: 'diurnal_auth_policy'
claims_policy: 'diurnal_claim_policy'
jwks_uri: 'https://auth.example.com/jwks.json'
public: 'false'
grant_types:
- 'authorization_code'
redirect_uris:
- 'https://diurnal.example.com/oauth2/callback/oidc'
response_types:
- 'code'
scopes:
- 'email'
- 'groups'
- 'openid'
- 'profile'
access_token_signed_response_alg: 'none'
userinfo_signed_response_alg: 'none'
token_endpoint_auth_method: 'client_secret_post'
introspection_endpoint_auth_method: 'client_secret_post'Failed login and registration attempts are rate-limited per client IP address. Once an IP exceeds
AUTH_IP_THROTTLE_MAX_ATTEMPTS failures within the AUTH_IP_THROTTLE_LOCKOUT_DURATION window, it is locked out of both logging in and
registering. When blocked, the API returns 429 (with a Retry-After header). The IP comes from TRUST_X_FORWARDED_HEADERS.
Durations are ISO-8601 (e.g. PT5M = 5 minutes, PT1H = 1 hour, PT30S = 30 seconds).
| Variable | Default | Description |
|---|---|---|
AUTH_IP_THROTTLE_ENABLED |
true |
Set to false to disable throttling |
AUTH_IP_THROTTLE_MAX_ATTEMPTS |
15 |
Failures from one IP before it is locked out |
AUTH_IP_THROTTLE_LOCKOUT_DURATION |
PT15M |
How long an IP stays locked |
Both the web UI and the REST API authenticate against a server-side session store (the sessions table). Logging in mints a random opaque token,
delivered as the diurnal_session cookie (web) or a Bearer token (API); only its hash is stored, and every session is revocable. Logging out,
changing your password (which signs out every other device), or "Log out from everywhere" in Settings all delete session rows. No keys or secrets to
manage.
A session ends at whichever comes first: SESSION_IDLE_TIMEOUT since it was last used, or SESSION_ABSOLUTE_LIFETIME since it was created. Both are
ISO-8601 durations (e.g. P30D = 30 days, P7D = 7 days, PT12H = 12 hours).
| Variable | Default | Description |
|---|---|---|
SESSION_IDLE_TIMEOUT |
P30D |
Sliding idle timeout; a session dies this long after its last use |
SESSION_ABSOLUTE_LIFETIME |
P90D |
Hard cap on a session's age regardless of activity |
SESSION_CLEANUP_INTERVAL |
PT1H |
How often expired sessions are swept from the database |
Diurnal serves plaintext HTTP and is designed to run behind a TLS-terminating reverse proxy. The proxy should handle everything TLS-related: the
certificate, any HTTP→HTTPS redirect, and the Strict-Transport-Security (HSTS) header.
| Variable | Default | Description |
|---|---|---|
TRUST_X_FORWARDED_HEADERS |
true |
Trust X-Forwarded-* headers from the reverse proxy |
By default, only same-origin browsers can call Diurnal, so any third-party web app running in a browser on another origin is blocked by CORS. To
let a web app from https://myapp.example.com call your Diurnal instance, for example, set this on the diurnal container:
environment:
CORS_ALLOWED_ORIGINS: "https://myapp.example.com"| Variable | Default | Description |
|---|---|---|
CORS_ALLOWED_ORIGINS |
Comma-separated list of origins allowed to call the API from a browser (unset = none) |
Each user can customise Diurnal from the Settings page (top-right menu).
- Email: Your login identity (cannot be changed)
- Display name: The name shown in the app
- Theme
- System
- Light
- Dark
- Calendar Style: How the dashboard calendar draws each day (see screenshots above)
- Full
- Minimal
- Stacked
- Font
- Nova
- Standard
- Timezone: The timezone used to decide what "today" is so day boundaries line up with your timezone
- Items per page: Page size for lists (actions, day panel, stats, etc.)
Each theme, calendar, and font option shows a thumbnail and has a full-size preview.
The first account to register is an administrator. Administrators get two extra sections:
- Admin → Users: View and manage user accounts (delete or edit role)
- API: The Swagger UI for the session-token-secured REST API, useful for scripting or integrating Diurnal with other tools.
Diurnal exposes a versioned public REST API at /api/v1 for building integrations and mobile apps. Administrators can open the Swagger UI from
the API link in the navbar. Authenticate by exchanging credentials for a session token, then send it as a Bearer header:
TOKEN=$(curl -s -X POST https://diurnal.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"correct horse battery staple"}' | jq -r .token)
curl -s https://diurnal.example.com/api/v1/actions -H "Authorization: Bearer ${TOKEN}"This project follows Semantic Versioning (MAJOR.MINOR.PATCH). Generally, if a user must change something it's a MAJOR
update, if they can use something new it's a MINOR, else it's a PATCH.
- MAJOR: A change that breaks an existing deployment or integration on upgrade.
- Database migration that cannot be applied to an existing database
- Incompatible changes to the public REST API (
/api/v1/*) - Removed or renamed configuration options / environment variables
- Removal of a user-facing feature that existing users actively rely on
- MINOR: Backwards-compatible new functionality.
- Additive database migrations
- New REST endpoints or fields, new configuration options (with safe defaults)
- New settings, calendar views, links, or pages
- Major visual/styling updates, like new branding, re-theming the application, etc.
- PATCH: Backwards-compatible fixes and internal changes.
- Bug fixes
- Codebase refactoring
- Dependency bumps
- Minor visual/styling updates and behaviours, like better resizing for mobile views, etc.
Diurnal is released under the BSD Zero Clause License.








