How I Synced Our Family iCloud Calendar to Google Calendar (Without Making It Public)
We have a Google Home in our kitchen, and my wife wanted to see our family calendar events on it. Simple enough request — except our family calendar lives in iCloud, and Google Home only reads Google Calendar.
The “easy” fix Apple offers is to publicly expose your iCloud calendar via a shareable link. I wasn’t thrilled about broadcasting our family’s schedule to the entire internet, so I went looking for another way.
What I landed on: a small AWS Lambda function that authenticates directly with both iCloud and Google Calendar, pulls events from iCloud, and syncs them into a Google Calendar — no public exposure required.
How It Works
At a high level, the Lambda function does three things:
- Fetches events from iCloud using CalDAV (the protocol Apple uses under the hood)
- Archives a copy of the calendar as an
.icalfile in S3 (useful as a backup or for other integrations) - Syncs events to Google Calendar using the Google Calendar API, with a two-pass update/insert strategy to handle both new and existing events
The sync runs on a schedule via EventBridge, so the Google Calendar stays current automatically.
Prerequisites
Before deploying, you’ll need to set up a few things:
- Apple App Password — iCloud requires a dedicated app password for programmatic access, not your main Apple ID password. Generate one at appleid.apple.com.
- S3 Bucket — used to store a copy of the
.icalexport. - Google Cloud Service Account — needs write access to the target Google Calendar. You’ll share the calendar with the service account’s email address.
The Lambda Function
import os
import json
import boto3
import caldav
import hashlib
import datetime
import time
import random
from icalendar import Calendar as iCal
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
s3 = boto3.client('s3')
def get_gcal_service():
service_info = json.loads(os.environ['GOOGLE_SERVICE_ACCOUNT_JSON'])
creds = service_account.Credentials.from_service_account_info(
service_info,
scopes=['https://www.googleapis.com/auth/calendar']
)
return build('calendar', 'v3', credentials=creds)
def clean_id(uid):
return hashlib.md5(uid.encode()).hexdigest().lower()
def format_dates(component):
dt_start = component.get('dtstart').dt
dt_end = component.get('dtend').dt
if not isinstance(dt_start, datetime.datetime):
if dt_end <= dt_start:
dt_end = dt_start + datetime.timedelta(days=1)
return {'date': dt_start.strftime('%Y-%m-%d')}, {'date': dt_end.strftime('%Y-%m-%d')}
def normalize_to_utc_string(dt_obj):
if dt_obj.tzinfo is None:
return dt_obj.strftime('%Y-%m-%dT%H:%M:%SZ')
return dt_obj.astimezone(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
s_val = dt_start if dt_start.tzinfo else dt_start.replace(tzinfo=datetime.timezone.utc)
e_val = dt_end if dt_end.tzinfo else dt_end.replace(tzinfo=datetime.timezone.utc)
if e_val <= s_val:
e_val = s_val + datetime.timedelta(minutes=1)
return {'dateTime': normalize_to_utc_string(s_val)}, {'dateTime': normalize_to_utc_string(e_val)}
def execute_batch_with_retry(batch, max_retries=5):
for attempt in range(max_retries):
try:
return batch.execute()
except HttpError as e:
if e.resp.status in [403, 429, 500, 503]:
wait = (2 ** (attempt + 1)) + random.random()
print(f"Throttled ({e.resp.status}). Retrying in {wait:.2f}s...")
time.sleep(wait)
else:
raise e
def lambda_handler(event, context):
try:
client = caldav.DAVClient(
url="https://caldav.icloud.com",
username=os.environ['APPLE_ID'],
password=os.environ['APP_PASSWORD']
)
selected_calendar = next(
(c for c in client.principal().calendars() if c.name == os.environ.get('CALENDAR_NAME', 'Family')),
None
)
events = selected_calendar.date_search(
datetime.datetime.now() - datetime.timedelta(days=730),
datetime.datetime.now() + datetime.timedelta(days=365)
)
all_vevents = {}
new_ical = iCal()
for ev in events:
for component in iCal.from_ical(ev.data).walk('VEVENT'):
sid = clean_id(str(component.get('uid')))
all_vevents[sid] = component
new_ical.add_component(component)
s3.put_object(
Bucket=os.environ['S3_BUCKET_NAME'],
Key=os.environ['S3_FILE_KEY'],
Body=new_ical.to_ical()
)
g_service = get_gcal_service()
g_cal_id = os.environ['GOOGLE_CALENDAR_ID']
needs_insert = []
def sync_cb(request_id, response, exception):
if exception:
if exception.resp.status == 404:
needs_insert.append(request_id)
elif exception.resp.status not in [403, 429, 503]:
print(f"Sync Error for {request_id}: {exception}")
items = list(all_vevents.items())
BATCH_SIZE = 10
# Pass 1: Attempt update for all events
for i in range(0, len(items), BATCH_SIZE):
batch = g_service.new_batch_http_request(callback=sync_cb)
for safe_id, component in items[i:i + BATCH_SIZE]:
s_data, e_data = format_dates(component)
body = {
'summary': str(component.get('summary', 'No Title')),
'start': s_data,
'end': e_data,
'description': str(component.get('description', '')),
'location': str(component.get('location', ''))
}
batch.add(g_service.events().update(
calendarId=g_cal_id, eventId=safe_id, body=body, quotaUser=safe_id[:10]
), request_id=safe_id)
execute_batch_with_retry(batch)
time.sleep(3)
# Pass 2: Insert any that didn't exist yet
if needs_insert:
for i in range(0, len(needs_insert), BATCH_SIZE):
batch = g_service.new_batch_http_request(callback=sync_cb)
for safe_id in needs_insert[i:i + BATCH_SIZE]:
comp = all_vevents[safe_id]
s_data, e_data = format_dates(comp)
body = {
'id': safe_id,
'summary': str(comp.get('summary', 'No Title')),
'start': s_data,
'end': e_data
}
batch.add(g_service.events().insert(
calendarId=g_cal_id, body=body, quotaUser=safe_id[:10]
), request_id=safe_id)
execute_batch_with_retry(batch)
time.sleep(3)
return {"statusCode": 200, "body": "Sync complete."}
except Exception as e:
print(f"CRITICAL: {str(e)}")
return {"statusCode": 500, "body": str(e)}
A Few Design Decisions Worth Noting
Event IDs are MD5 hashes of the iCloud UID. Google Calendar requires event IDs to be lowercase alphanumeric, and iCloud UIDs are not. Hashing them gives a stable, deterministic ID — the same iCloud event always maps to the same Google event ID, which makes updates idempotent.
Update first, insert on 404. Rather than checking whether each event exists before writing it, the function attempts an update on everything. Any event that comes back 404 gets queued for insert in a second pass. This avoids an extra round-trip per event.
update instead of patch. Google’s patch method can conflict when an event switches between all-day and timed formats. Using update replaces the entire resource and sidesteps that issue cleanly.
Exponential backoff on rate limits. The Google Calendar API will throttle heavy batch requests. The retry logic handles 429/503 responses with exponential backoff and a small random jitter to avoid thundering herd behavior.
Environment Variables
| Variable | Description |
|---|---|
APPLE_ID | Your Apple ID email |
APP_PASSWORD | App-specific password from Apple |
CALENDAR_NAME | Name of the iCloud calendar (default: Family) |
GOOGLE_SERVICE_ACCOUNT_JSON | Full JSON key for the service account |
GOOGLE_CALENDAR_ID | Target Google Calendar ID |
S3_BUCKET_NAME | Bucket for the .ical backup |
S3_FILE_KEY | Object key for the .ical file (e.g., family.ical) |
Future Improvements
This is a one-way, append-only sync — it handles new events well but has two notable gaps:
Deletions aren’t propagated. If you delete an event from iCloud, it will disappear from the CalDAV fetch but the corresponding Google Calendar event is never removed. Over time, your Google Calendar will accumulate ghost events.
Updates aren’t fully reliable. The update pass will overwrite the title, time, description, and location of a changed event — but only if the event’s UID stays the same. Some calendar clients regenerate UIDs on certain edits, which would cause the old event to linger and a duplicate to be inserted.
The fix for both is the same: maintain a persistent record (a DynamoDB table or a JSON file in S3) of every event ID the Lambda has ever synced. On each run, compare that set against what iCloud returned. Any ID that was in the previous run but is absent from the current one should be explicitly deleted from Google Calendar via events().delete(). This would make the sync truly idempotent and bring Google Calendar to an exact mirror of iCloud on every execution.
The Result
The Google Home now shows our family calendar events, our iCloud calendar stays private, and the whole thing runs quietly on a schedule without any manual intervention. Total AWS cost is effectively nothing — a Lambda running a few times a day and a few KB written to S3.
