Calendar Hub consolidates subscribed calendars (for example, healthcare portals or generic ICS feeds), normalizes event data, and syncs everything into a single Apple Calendar via CalDAV. The app is built for self-hosting, ships with Docker support, and manages its own encryption keys—no master key required.
- Unify multiple ICS feeds into one Apple Calendar collection with per-source overrides.
- UI for managing sources, testing destinations, and monitoring upcoming events in real time.
- Event mapping and filter rules to normalize titles or drop noise before syncing.
- Background sync pipeline backed by Solid Queue, plus manual sync and pause controls.
- Credential encryption with automatic key management and rotation tools in Settings.
- Ruby 3.4.5 (see
.ruby-version) - Rails 8 with Hotwire (Turbo + Stimulus) and Tailwind CSS
- Solid Cache / Solid Queue / Solid Cable on SQLite
- Thruster app server; Faraday and Nokogiri for HTTP + parsing
- Quality tooling: Toys task runner, RuboCop suite, ERB Lint, Brakeman, Minitest/WebMock, SimpleCov
- Ruby 3.4.5 with Bundler (
gem install bundler) - SQLite 3 (ships with macOS and most Linux distributions)
bin/setupbin/setup installs gems, prepares the database, clears temp files, and starts the development Procfile (bin/dev). Pass --skip-server to avoid automatically launching the dev server.
bin/devbin/dev runs the Rails server, Tailwind watcher, and Solid Queue worker defined in Procfile.dev.
- Visit
Settingsto enter your Apple ID username and the app-specific password generated at appleid.apple.com. - Credentials are stored encrypted using the app's credential key (see below) and are only used for CalDAV operations.
- In
Settings(/settings/edit), set the default Apple Calendar Identifier to match the display name shown in Apple Calendar (for example,Work). - Override the identifier per
CalendarSourcewhen you need a source to write to a different calendar.
- Each
CalendarSourcedefines an ingestion URL and sync options such as frequency, windows, and default time zone. - Event mappings let you rewrite titles or locations before they sync; filters can drop junk events entirely.
- Use the source detail page to run "Check Destination", force syncs, or archive/purge sources without deleting historic events.
- On first boot the app generates:
storage/key_store.json– JSON document containing the credential encryption key andsecret_key_base.
- Rotate the credential key from Settings → Rotate Credential Key. Rotation re-encrypts all stored credentials in-place.
- Override the key location with
CALENDAR_HUB_CREDENTIAL_KEY_PATHif you need to store it outside the repository path. - Persist the entire
storage/directory (and optionallylog/) between deployments or container restarts to retain credentials, secret keys, and SQLite databases.
Set APP_HOST, APP_PROTOCOL, and APP_PORT if you need generated URLs in emails or background jobs to point at a non-default hostname or port. Settings in the UI take precedence over environment variables when present.
CalendarSourceconfigures the ingestion endpoint and target calendar.CalendarHub::Ingestion::GenericICSAdapterfetches and normalizes ICS data.- Events persist to
CalendarEvent, are broadcast via Turbo, and appear on the dashboard. CalendarHub::SyncServiceinvokesAppleCalendar::Clientto upsert/delete events over CalDAV.SyncCalendarJob(Solid Queue) orchestrates background work; you can trigger manual syncs or pauses from the UI.- Monitor background job throughput at
/admin/jobs(Mission Control Jobs) and real-time connectivity at/realtime.
toys checkstoys checks runs RuboCop, ERB Lint, Brakeman, and the full Minitest suite. Coverage reports land in coverage/ (open with toys cov). Run these checks before committing changes.
docker build -t youruser/calendar_hub .
docker run -d \
-p 80:80 \
-v calendar_hub_storage:/rails/storage \
-v calendar_hub_log:/rails/log \
--env APP_HOST=calendar.example.com \
--name calendar_hub \
youruser/calendar_hubNotes:
- No master key is required.
SECRET_KEY_BASEis optional; when absent the container writes both keys intostorage/key_store.jsonon first boot. bin/docker-entrypointrunsbin/rails db:preparebefore starting the Thruster server listening on port 80.- Map
storage/to a persistent volume to keep encrypted credentials, secret keys, and SQLite databases. - Override
CMDorPORTif your platform expects a different process or port.
.github/workflows/deploy.ymlbuilds and pushesghcr.io/<owner>/calendar_hubwhen theVERSIONfile changes onmain(release workflow).- Image tags include
latest, the gitsha, and (if present) the value ofVERSION.
- Required: none.
- Recommended:
APP_HOST,APP_PROTOCOL,APP_PORT– canonical host/protocol/port for generated URLs.PORT– override default Thruster port (80 in Docker, 3000 locally if you runrails server).
- Optional operational knobs:
SECRET_KEY_BASE– supply your own secret; otherwise generated insidestorage/key_store.json.CALENDAR_HUB_KEY_STORE_PATH– custom path for the combined key store (defaults tostorage/key_store.json).CALENDAR_HUB_CREDENTIAL_KEY_PATH– legacy path override for the credential key; still honored for compatibility.APPLE_READONLY=true– sync without issuing CalDAV deletes.SOLID_QUEUE_SEPARATE_WORKER=true– run jobs in a separate worker process instead of the web process (by default jobs run inside Puma).WEB_CONCURRENCY,JOB_CONCURRENCY,RAILS_MAX_THREADS– tune Puma and Solid Queue concurrency.RAILS_LOG_LEVEL– set log verbosity (infoby default).WARM_CACHE_ON_STARTUP=false– opt out of cache warming (defaults totruein production,falseelsewhere).
- Realtime updates in development: ensure
bin/devis running thewebandjobsprocesses; test broadcast connectivity at/realtime→ "Send Test Broadcast". - CalDAV 400/403 errors: use "Check Destination" on the source to confirm the discovered collection is writable.
- No events syncing: verify the source is Active with a valid ICS URL, the Pending count is > 0, and credentials are present; use "Force Sync" if the sync window blocks processing.
- Credential key mismatch: if the credential key file is lost, restore it from backup or rotate the key in Settings; without it previously stored credentials cannot be decrypted.