Skip to content
ellitePublic

About

Candlr - a self-hosted birthday / anniversary calendar and reminder

Topics

Resources

Stars

42 stars

Watchers

1 watching

Forks

Repository files navigation

Candlr Logo

Candlr

Open-source, self-hosted birthday calendar.

GitHub Stars Docker Pulls GitHub Contributors GitHub Sponsors Latest Release Build


Candlr keeps track of everyone's birthdays in one place, so you never miss one again. Ships as a single Docker container with SQLite - no external database to manage.

Table of Contents

Features

  • Track anyone's birthdays, anniversaries, or your own event types: Each card can hold several dates (a birthday and a wedding anniversary on the same card, for example). Add a photo by upload or by pasting an image URL, then zoom and reposition it with the built-in crop editor before saving. Notes support Markdown. The year is optional, if you don't know or don't want to record it, Candlr just tracks the month and day.
  • Bulk actions: Select several cards on the Events page to delete them, change one date type to another (say Birthday to a custom type), or turn reminders on or off for all their dates in one go.
  • Custom event types with any cadence: each event type repeats as often as you choose, such as every year, every month, every 37 days, every 4 weeks or every 5 years. Reminders, the calendar and the iCalendar feed all follow it.
  • Import and export: Download all your cards as CSV or JSON from the settings page, or import from either format, or from a vCard (.vcf) export of your address book, such as Radicale, Baïkal or Nextcloud (merging into what you already have, so importing the same file twice changes nothing). Photos are not included.
  • Calendar subscription: A private iCal link (Subscribe button on the Calendar page) that puts every date in Google Calendar, Apple Calendar, Thunderbird or any app that can subscribe to a URL, repeating yearly and updating as you add cards. Regenerate or turn off the link at any time.
  • OIDC / SSO: Sign in with any OpenID Connect provider (Authelia, Authentik, Keycloak, etc.), with optional auto-created accounts.
  • Notification channels: Email, ntfy, Discord, Telegram, Pushover, Gotify, and browser/device push, configured per account from the settings page, each with a "send test" button, plus an automatic reminder on the day itself.
  • Dark / light theme: Follows your system preference by default, with a manual toggle in the nav bar and on the login/register pages.
  • SQLite storage: A single file database - no separate database container to run or maintain.
  • Single container: Frontend and backend ship together - no separate services to manage.

Screenshots

Dashboard

View more screenshots

Events Events

Calendar Calendar

Dashboard (dark) Dashboard dark

Dashboard (mobile) Dashboard mobile

Dashboard (mobile, dark) Dashboard mobile dark

Events (mobile) Events mobile

Calendar (mobile) Calendar mobile

Getting Started

Prerequisites

Images are hosted on Docker Hub (bellamy/candlr). A mirror is also available on GHCR (ghcr.io/ellite/candlr) if you prefer.

Docker Compose

  1. Download the compose file:
curl -o docker-compose.yml https://raw.githubusercontent.com/ellite/candlr/main/docker-compose.yml
  1. Generate a secret key and set it in docker-compose.yml:
# Python
python3 -c "import secrets; print(secrets.token_hex(32))"

# OpenSSL
openssl rand -hex 32
services:
  candlr:
    image: bellamy/candlr:latest
    container_name: candlr
    restart: unless-stopped
    ports:
      - "4258:4258"
    environment:
      - PUID=1000
      - PGID=1000
      - SECRET_KEY=changeme   # ← generate with: openssl rand -hex 32
      - TIMEZONE=America/New_York   # ← IANA name; defaults to UTC
    volumes:
      - ./data:/app/backend/data
  1. Start:
docker compose up -d

Docker Run

docker run -d \
  --name candlr \
  --restart unless-stopped \
  -p 4258:4258 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e SECRET_KEY="$(openssl rand -hex 32)" \
  -e TIMEZONE=America/New_York \
  -v ./data:/app/backend/data \
  bellamy/candlr:latest

First Setup

  1. Open http://localhost:4258 in your browser.
  2. Register an account - all data is local to your instance.

Updating

docker compose pull && docker compose up -d

Database migrations run automatically on startup - no manual steps required.

Configuration

Variable Default Description
SECRET_KEY - Required. JWT signing and 2FA secret encryption key. Keep stable and backed up. Generate with openssl rand -hex 32.
PUID 1000 User ID to run the process as.
PGID 1000 Group ID to run the process as.
COOKIE_SECURE false Set to true when serving over HTTPS, so the auth cookie is marked secure.
DOCS_ENABLED false Set to true to expose /docs and /redoc on the backend.
ENABLE_REGISTRATIONS false The very first account can always be created; this gates every account after that.
REGISTRATION_MAX_ALLOWED_USERS 0 0 means unlimited. Only checked when ENABLE_REGISTRATIONS is true.
BACKEND_PORT 8000 Internal port the backend binds to. Override only if 8000 conflicts on bare metal.

See OIDC / Single Sign-On and Notifications below for those variables.

OIDC / Single Sign-On

Candlr can authenticate against any OpenID Connect provider (Authelia, Authentik, Keycloak, and similar) instead of, or alongside, local password accounts.

  1. Register Candlr as a client/application with your provider, with the redirect URI set to http://your-server:4258/oidc-callback.
  2. Set the following environment variables:
Variable Default Description
OIDC_ENABLED false Turns on the "Sign in with ..." button on the login page.
OIDC_PROVIDER_NAME SSO Shown on the login button, e.g. "Sign in with Authentik".
OIDC_CLIENT_ID - From your provider.
OIDC_CLIENT_SECRET - From your provider.
OIDC_AUTH_URL - The provider's authorization endpoint.
OIDC_TOKEN_URL - The provider's token endpoint.
OIDC_USERINFO_URL - The provider's userinfo endpoint.
OIDC_REDIRECT_URL http://localhost:4258/oidc-callback Must match what's registered with the provider.
OIDC_IDENTIFIER_FIELD email Field in the userinfo response used to match/create the local account.
OIDC_SCOPES openid email profile
OIDC_AUTO_CREATE_USERS true If false, only users who already exist locally can sign in via SSO.
OIDC_DISABLE_PASSWORD_LOGIN false Hides the username/password form entirely; login and register become SSO-only.
OIDC_TRUST_PROVIDER_2FA false Skips Candlr's own authenticator-app step for SSO logins, for when your provider already enforces multi-factor. Password logins still need it. Only enable this if the provider really requires MFA, since otherwise an SSO login with a single factor reaches accounts that have Candlr 2FA on.

The first person to sign in, local or via OIDC, becomes the instance admin.

Two-factor authentication

Accounts with a local password can enable authenticator-app 2FA under Settings > Two-factor authentication. Confirm your password, scan the QR code (or enter the setup key manually), and confirm the six-digit code. Save the ten single-use recovery codes before leaving setup. Settings also lets you replace recovery codes or turn off 2FA using your password and an authenticator or recovery code.

Both password and SSO sign-in require the second step when Candlr 2FA is enabled, unless the admin sets OIDC_TRUST_PROVIDER_2FA=true to let SSO logins rely on the provider's own multi-factor check (password sign-in still asks for the code). SSO-only accounts manage their second factor with their identity provider. Password reset does not remove 2FA. Enabling or disabling 2FA signs out other sessions.

Setup expires after ten minutes and sign-in challenges after five minutes. Five failed verification attempts lock further attempts for five minutes. Authenticator codes cannot be reused. TOTP follows RFC 6238 through PyOTP.

Authenticator secrets are encrypted using a key derived from SECRET_KEY; recovery codes and login challenges are stored hashed. Keep SECRET_KEY stable and backed up with your database. Changing it makes stored authenticator secrets unreadable. Serve the app over HTTPS and enable COOKIE_SECURE in production.

Address book sync (CardDAV)

Settings > Address book sync connects one CardDAV address book (Radicale, Baïkal, Nextcloud and the like) per account. Enter the address book's own URL (for Nextcloud, https://cloud.example.com/remote.php/dav/addressbooks/users/NAME/contacts/), a username and a password, ideally an app password.

Sync is one way and the address book is the master copy. Every contact with a BDAY, ANNIVERSARY or X-ANNIVERSARY becomes a card with a Birthday or Anniversary date, matched by the contact's UID, so renaming a contact renames its card instead of duplicating it. A card you already made with the same name is adopted on the first sync. Names and dates are overwritten by each sync; notes and the reminder switch on a date are kept. A contact deleted in the address book removes its card, unless the server returns no dated contacts at all, which is treated as a wrong URL and changes nothing. Photos are not synced, and nothing is ever written back to the address book.

An address book on a private address (a Radicale or Nextcloud on your LAN or in another container) needs that address in INTERNAL_IP_ALLOW_LIST, see below. The worker syncs every connected address book on a schedule, and Sync now does it on demand. The password is stored encrypted with a key derived from SECRET_KEY, so keep that stable.

Variable Default Description
CARDDAV_SYNC_INTERVAL_MINUTES 360 How often the worker syncs each address book. 0 turns the scheduled sync off.

Internal addresses (INTERNAL_IP_ALLOW_LIST)

Several features make the server fetch a URL a user typed: person photo URLs, CardDAV address books, and the ntfy, Discord and Gotify notification channels. To stop those being used to reach services on the server's own network (including the cloud metadata endpoint), Candlr only connects to public addresses. Anything on a private or internal network, such as localhost, 192.168.x.x, 10.x.x.x or a Docker container, is refused until an admin allows it.

Allow what you need with INTERNAL_IP_ALLOW_LIST, a comma separated list of entries:

Entry Allows
10.0.0.5 that address, on any port
10.0.0.5:3001 that address, on that port only
192.168.1.0/24 every address in the network (192.168.1.0/24:8080 limits it to a port)
gotify:80 the hostname gotify (a Docker service name, say) on that port; use it where container addresses change
[fd00::5]:8080 an IPv6 address, with an optional port

For example, INTERNAL_IP_ALLOW_LIST=10.0.0.5:3001,gotify:80,192.168.1.0/24. Entries with no port allow any port, and URLs without a port use 80 for http and 443 for https. A hostname entry matches the name in the URL, and the other entries match the address that name resolves to; every address it resolves to has to pass. Link-local addresses (169.254.x.x, fe80::/10) are never allowed, even if listed. Entries that can't be parsed are ignored with a warning in the log. Users who enter a blocked URL see a message naming the entry to add.

This is a breaking change for instances where a ntfy, Discord or Gotify server, address book or photo host sits on a private address: add it to the list when upgrading. The check happens when a channel or address book is saved and again when it is used. When a request is made, the hostname is resolved once, every address is checked, and the connection goes to one of those exact addresses (the hostname is still used for the Host header and for verifying the TLS certificate), so a DNS answer that changes between the check and the connection cannot redirect the request. Redirects are never followed automatically; photo downloads re-check every hop.

Notifications

Each account can configure its own notification channels from the settings page, plus what time of day to be notified (always on the day itself) and, per date, whether to notify for it at all. The reminder worker checks every minute in TIMEZONE and sends one message per enabled date on every enabled channel. It catches up later the same day after downtime, but does not send reminders from previous days. Delivery history in SQLite prevents routine repeat sends across restarts; failed channels retry after 5, 10, 20, 40, then 60 minutes, only while still eligible that day. A crash after a provider accepts a message but before success is recorded can still cause a duplicate. Web push follows the existing channel behavior: delivery to any subscribed device counts as channel success.

Docker starts the worker automatically under supervisord after migrations. For local development, run alembic upgrade head, then python -m app.reminders from backend/ alongside the API and frontend.

  • Email and device (browser push) need instance-wide setup (below) before any account can use them. Everything else is entirely self-serve from the settings page.
  • ntfy, Discord, Telegram, Pushover, and Gotify need nothing from you as the admin; each user pastes in their own topic/webhook/bot/app details.
Variable Default Description
SMTP_ADDRESS - Your SMTP relay's hostname. Leave unset to disable the email channel entirely.
SMTP_PORT 587
SMTP_ENCRYPTION tls tls, ssl, or none.
SMTP_USERNAME / SMTP_PASSWORD -
FROM_EMAIL - Falls back to SMTP_USERNAME, then candlr@localhost.
SERVER_URL http://localhost:4258 Used to build the link inside the password reset email. Set this to your real external URL.
TIMEZONE UTC IANA name (e.g. America/New_York). Determines "today" for the dashboard and the local time used for reminders.
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY - Required for the device-notifications (web push) channel. Generate a pair with:
docker exec candlr python3 /app/backend/scripts/generate_vapid_keys.py

(or, in a local dev checkout: cd backend && python3 scripts/generate_vapid_keys.py)

Setting SMTP_ADDRESS also turns on forgot/reset password: a "Forgot password?" link appears on the login page, and it sends a reset link through the same SMTP relay. Leave it unset and that link simply doesn't appear.

Data

All data is stored under ./data: the SQLite database at ./data/candlr.db, and any uploaded or fetched photos under ./data/images/. Back up the whole data/ directory to preserve your birthdays and photos together.

The data/ directory is a bind mount, so it persists across container rebuilds and restarts.

Port Service
4258 Candlr web UI

The backend API is internal-only and not exposed outside the container.

Development

View instructions

Requirements

  • Python 3.12+
  • Node.js 22+

Backend

cp .env.example .env   # in the repo root - then edit .env, at minimum set SECRET_KEY
cd backend
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload --port 8000

To run the backend tests, run python -m unittest discover tests from backend/.

Frontend

cd frontend
npm install
API_URL=http://localhost:8000 npm run dev

The frontend dev server starts on http://localhost:4258 and proxies API calls to the backend on port 8000.

Contributing

Contributions are welcome - whether it's a bug report, a feature request, or a pull request.

  • Issues: Open an issue for bugs, questions, or feature ideas.
  • Pull Requests: Fork the repo, create a branch, and submit a PR. Please follow the existing code style (Astro components for the frontend, FastAPI for the backend).

Commit messages follow Conventional Commits - feat:, fix:, chore: - as releases and changelogs are generated automatically from them.

Contributors

License

Candlr is licensed under the GNU General Public License v3.0.

You are free to use, modify, and distribute Candlr, provided that any derivative works are also released under the GPLv3.

About

Candlr - a self-hosted birthday / anniversary calendar and reminder

Topics

Resources

Stars

42 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages