The Problem: Calendar List Overload#
If you’ve ever subscribed to multiple calendar feeds, you know the pain. My wife and I juggle appointments across multiple healthcare providers (RMTs, chiropractors, therapists, etc) all using JaneApp for online booking. And while I love JaneApp and will actively bias myself to choose providers that use it for booking, with each provider having their own JaneApp ICS calendar subscription things get out of hand fast.
The result? Our Apple Calendar sidebar looked like this (which believe it or not is still leaving things out):
- My RMT Calendar
- My Chiropractor Calendar
- My Therapist Calendar
- Wife RMT Calendar
- Wife Chiropractor Calendar
- Wife Therapist Calendar
- Kids School Calendar
So many separate calendars, just to see our appointments. And that school calendar? It included every event for every grade and extra-curricular activity, a lot of which were completely irrelevant to our kids’ actual schedule.
Calendar subscriptions are great in theory, but they fall short in practice:
- No consolidation: Each feed = separate calendar in your list
- No filtering: You get everything or nothing
- No control: Can’t merge, customize, or selectively hide events
- Visual clutter: Sidebar bloat makes navigation painful
I needed a way to subscribe to all these feeds while maintaining a clean, organized family calendar view.
The Solution: Calendar Hub#
Calendar Hub is a self-hosted Rails application I created that consolidates multiple ICS calendar subscriptions into a single Apple Calendar via CalDAV. Think of it as a smart calendar proxy with filtering capabilities.
Here’s what it does:
- Subscribes to multiple ICS feeds (JaneApp, school calendars, any
.icsURL) - Normalizes event data across different sources
- Maps and filters events with configurable rules to rename titles or drop unwanted events
- Syncs the consolidated events to your Apple Calendar using CalDAV
- Updates automatically in the background with real-time UI feedback
Result: So many messy calendar subscriptions → One clean, filtered calendar.
Key Features#
Multi-Source Consolidation#
Add as many ICS sources as you want. Each source can point to a different calendar in your Apple Calendar, or they can all feed into one consolidated calendar. Perfect for:
- Multiple JaneApp provider calendars
- School/district event feeds
- Work calendars from different systems
- Public event calendars (holidays, sports schedules, etc.)
Encrypted Credential Storage#
Calendar sources sometimes require HTTP Basic authentication. Calendar Hub encrypts these credentials along with your Apple ID app-specific password using AES-256 encryption with automatic key management. The credential encryption key is auto-generated on first boot and can be rotated from the Settings page, which re-encrypts all stored credentials in-place. No plaintext secrets sitting in the database.
CalDAV Sync to Apple Calendar#
Rather than creating yet another calendar subscription, Calendar Hub acts as a CalDAV client that writes directly to an existing Apple Calendar. This means:
- Events appear in your chosen calendar (personal, iCloud, whatever)
- Full control over which calendar receives which sources
- Native Apple Calendar features work (alarms, invites, etc.)
- Works across all your devices (iPhone, iPad, Mac)
Real-Time UI with Hotwire#
The entire UI is built with Rails 8’s Hotwire stack (Turbo + Stimulus). When a sync runs:
- Event counts update live without page refresh
- Sync status changes broadcast instantly
- New events stream into the view as they’re ingested
- Background job progress visible in real-time
No React, no Vue, just server-rendered HTML with Turbo Streams over WebSockets.
Background Sync Jobs#
Syncing happens in the background via Solid Queue. You can:
- Manually trigger sync for any source
- Schedule automatic syncs with configurable frequency and time windows
- Pause/resume syncing per source
- Monitor sync history and errors
- View background job throughput at
/admin/jobsusing Mission Control
Technical Implementation#
The Stack#
Calendar Hub is built with modern Rails best practices:
- Ruby 3.4.5 - Latest stable Ruby
- Rails 8 - Fresh off the press with excellent defaults
- Hotwire - Turbo + Stimulus for reactive UI without heavy JavaScript
- Tailwind CSS - Utility-first styling
- Solid Cache / Queue / Cable - All backed by SQLite (no Redis needed!)
- Faraday - HTTP client for fetching ICS feeds
- Nokogiri - XML/HTML parsing for CalDAV and ICS
- Docker - Self-hosted deployment
Architecture Highlights#
The app is organized into clear service layers:
1. Ingestion Layer#
CalendarHub::Ingestion::GenericICSAdapter handles fetching and parsing ICS feeds:
adapter = CalendarHub::Ingestion::GenericICSAdapter.new(
source: calendar_source,
observer: observer
)
events = adapter.ingest
It:
- Fetches the ICS file over HTTP (with optional Basic Auth)
- Parses using a custom ICS parser
- Normalizes event fields (summary, start/end times, location, etc.)
- Persists to
CalendarEventmodels - Broadcasts updates via Turbo Streams
2. Translation Layer#
CalendarHub::Translators::EventTranslator converts normalized events to CalDAV format:
translator = CalendarHub::Translators::EventTranslator.new(event)
ical_data = translator.to_ical
Handles:
- All-day vs. timed events
- Timezone conversions
- UID generation for CalDAV
- VEVENT formatting
3. CalDAV Sync Layer#
AppleCalendar::Client manages CalDAV operations:
client = AppleCalendar::Client.new(username, password)
client.put_event(calendar_url, event_uid, ical_data)
client.delete_event(calendar_url, event_uid)
Implements:
- CalDAV service discovery (finding the right calendar collection URL)
- HTTP PROPFIND/REPORT/PUT/DELETE operations
- etag-based conflict detection
- Error handling for read-only calendars, auth failures, etc.
4. Orchestration#
CalendarHub::SyncService ties it all together:
service = CalendarHub::SyncService.new(source)
service.sync_all # Upsert all pending events
service.purge # Delete events removed from source
This is invoked by SyncCalendarJob running in Solid Queue.
Why Rails 8 + Solid Suite?#
Rails 8 shipped with the Solid suite (Cache, Queue, Cable) as defaults, all backed by SQLite. For a self-hosted app like Calendar Hub, this is perfect:
- No Redis dependency - One less service to manage in Docker
- Simple deployment - Just mount a volume for SQLite databases
- Rock solid - SQLite is incredibly reliable for read-heavy workloads
- Cost effective - Single container, minimal resources
CalDAV Deep Dive#
Working with CalDAV was… educational. A few hard-won lessons:
Service Discovery is Required#
You can’t just PUT to https://caldav.icloud.com/calendar. You need to:
- PROPFIND the principal URL for the user
- PROPFIND the calendar home set
- PROPFIND the specific calendar collection URL
Calendar Hub automates this with a “Check Destination” button that discovers the correct collection URL for a given calendar name.
Read-Only Calendars Fail Silently (Sort Of)#
Subscribed calendars in Apple Calendar are read-only. If you try to PUT to one, you get a 403. The app validates calendar writeability during setup.
UIDs Must Be Globally Unique#
Each event needs a unique UID that persists across updates. Calendar Hub generates these as:
{source_id}-{original_event_uid}@calendar-hub.local
This ensures the same source event always gets the same UID, enabling proper updates/deletes.
Timezones Are Fun#
CalDAV expects VTIMEZONE definitions in the VEVENT data. All-day events need DATE format (20250118), timed events need DATE-TIME with timezone (20250118T093000). Getting this right took… iteration.
Real-World Usage#
Calendar Hub has been running in production (my home Unraid server) for a few months now. The difference is night and day:
Before:
- 18+ calendars in sidebar
- Scrolling to find the right calendar
- School events for grades my kids aren’t in
After:
- 1 calendar: “Family Events”
- School events filtered using keyword rules
- All JaneApp appointments consolidated
- Clean sidebar, easy navigation
My wife actually thanked me for this one. That’s how you know it’s a win.
Self-Hosting with Docker#
Getting Calendar Hub running is straightforward:
Quick Start#
# Pull the pre-built image
docker pull ghcr.io/mmenanno/calendar_hub:latest
# Run with persistent storage
docker run -d \
--name CalendarHub \
-p '3000:3000/tcp' \
-e 'PUID'='99' \
-e 'PGID'='100' \
-v '/mnt/cache/appdata/calendar_hub':'/rails/storage':'rw' \
'ghcr.io/mmenanno/calendar_hub:latest'
Access at http://localhost:3000.
Want to build from source instead? Clone the repo and run docker build -t calendar_hub .
Configuration Steps#
- Settings → Enter Apple ID username + app-specific password
- Calendar Sources → Add an ICS source URL
- Check Destination → Let the app discover your calendar collection URL
- Activate → Enable the source
- Sync → Watch events flow in
Important Notes#
- Persistent Volume: The
/rails/storagevolume stores SQLite databases and auto-generated secrets. Don’t skip this or you’ll lose data on container restart. - App-Specific Password: Don’t use your actual Apple ID password. Generate an app-specific one at appleid.apple.com.
- Calendar Writeable: Target calendar must be personal/iCloud, not a subscription.
GitHub Container Registry#
I’ve set up GitHub Actions to auto-build and push to ghcr.io/mmenanno/calendar_hub whenever the VERSION file changes. The workflow creates GitHub releases and tags images with latest, the version number, and git SHA. You can pull pre-built images:
docker pull ghcr.io/mmenanno/calendar_hub:latest
Lessons Learned#
Rails 8 is a Pleasure#
The new defaults (Solid suite, modern asset pipeline) are chef’s kiss. Hotwire makes building reactive UIs so much simpler than the SPA madness.
CalDAV is Powerful but Quirky#
The spec is solid, but implementations vary. Apple’s CalDAV server has quirks (like requiring full VTIMEZONE definitions even when using UTC). Testing against real CalDAV servers is essential.
SQLite Scales Further Than You Think#
I have loved how flawlessly Solid Queue works for background jobs. Hundreds of events, multiple sources, background sync—no issues.
Self-Hosting is Satisfying#
There’s something deeply satisfying about running your own infrastructure. No SaaS subscriptions, no privacy concerns, full control. Plus, it’s just fun.
What’s Next?#
Calendar Hub scratches my itch, but there’s always room for improvement:
- Multi-User Support - Currently single-user; could support multiple Apple accounts with separate credential stores
- Enhanced Filter UI - The filter system works, but the UI for configuring complex rules could be more intuitive
- Calendar Event Viewer - Right now it’s just a sync engine; adding a web-based calendar view might be nice
- Webhook Support - Real-time updates when source calendars change instead of polling
- Notifications - Send notifications of updates to calendars to Discord / Slack / etc
The code is open source, so contributions are welcome! Feel free to open issues with ideas or suggestions for improvements.
Try It Yourself#
If you’re dealing with calendar subscription chaos, give Calendar Hub a try:
- GitHub: mmenanno/calendar_hub
- License: MIT
- Docs: See the README
The setup takes about 10 minutes, and the Docker image includes everything you need. If you run into issues, open a GitHub issue. I’m happy to help.
Have you dealt with calendar subscription overload? How did you solve it? I’d love to hear about alternative approaches or feature suggestions.
