Timeslot: A Privacy-First Self-Hosted Calendar Visibility System
目录
I wanted to show my availability on this blog — a simple “here’s when I’m free” widget. Every existing solution required either handing my calendar to a third-party service (Calendly, Cal.com cloud) or setting up OAuth credentials. None of them fit a static blog where I just want a read-only JSON feed.
So I built Timeslot — a self-hosted service that exposes your availability as a clean API, without sharing your calendar details with anyone.
How It Works#
Timeslot does one thing: show when you’re free. No appointment booking, no email flows — just availability.
Apple Calendar
Fastmail] -->|iCal URL| B(Timeslot) B -->|Parse busy blocks| C[(SQLite)] C -->|Query available slots| B B -->|JSON API| D[Your Blog
Static Site] B -->|Admin UI| E[You]
- Sync — Provide your private iCal URLs. Timeslot polls them every 15 minutes.
- Rules — Define your availability windows (e.g., Mon–Fri, 9 AM–5 PM).
- Query — The API subtracts busy blocks from your rules and returns free slots.
- Display — Your blog widget fetches the JSON and renders it.
Crucially, Timeslot only extracts busy/free status from your calendar — appointment titles, attendees, and descriptions never leave your server.
Why This Stack#
Go + single binary — one file to deploy, great stdlib for HTTP and concurrency, cross-compiles to ARM64 without fuss.
SQLite — no database server, no connection pools. Backups are cp timeslot.db timeslot.db.bak. On a personal tool processing one calendar, it’s the right choice.
Key Features#
| Feature | Detail |
|---|---|
| Privacy | Only parses busy/free blocks — no event details exposed |
| Universal iCal | Works with Google, Apple, Outlook, Fastmail — no OAuth or API keys |
| Lightweight | ~20 MB RAM, starts instantly, runs on a Raspberry Pi |
| OpenTelemetry | Tracing and metrics built-in for homelab observability |
| Docker + Helm | Ready for container and Kubernetes deployments |
Getting Started#
Prerequisites: a private iCal URL from your calendar provider (Google: Settings → Calendar → Export) and a VPS, Raspberry Pi, or local machine.
Docker (recommended):
docker run -d \
-p 8080:8080 \
-v $(pwd)/timeslot.db:/app/timeslot.db \
-e ADMIN_USER=admin \
-e ADMIN_PASSWORD=change-me \
ghcr.io/meirongdev/timeslot:latest
Or grab the binary from releases:
./timeslot
Minimal config.json:
{
"listen_addr": ":8080",
"db_path": "./timeslot.db",
"admin_user": "admin",
"admin_password": "change-me",
"slot_duration_min": 30,
"timezone": "UTC"
}
Query the API:
GET /api/slots?from=2026-03-01T09:00&to=2026-03-01T17:00
The JSON response embeds directly into Hugo, Jekyll, 11ty, or any static site.
Embedding in a Static Blog#
Here’s how this blog (meirong.dev/timeslot) integrates Timeslot — no framework, no dependencies, just a fetch call.
Hugo: rawhtml shortcode#
Hugo’s default Markdown renderer strips raw <script> and <style> tags for security. The workaround is a one-line shortcode that passes content through as-is:
{{/* layouts/shortcodes/rawhtml.html */}}
{{- .Inner | safeHTML -}}
Then in any .md page:
{{< rawhtml >}}
<div id="ts-widget">...</div>
<script>/* widget JS */</script>
{{< /rawhtml >}}
The Widget#
The widget is ~60 lines of vanilla JS. It fetches the next 14 days of slots, groups them by date, and renders a list:
(function () {
const API_BASE = "https://slot.meirong.dev/api";
const TZ = "Asia/Shanghai";
async function fetchSlots() {
const now = new Date().toISOString();
const in14d = new Date(Date.now() + 14 * 864e5).toISOString();
const resp = await fetch(`${API_BASE}/slots?from=${encodeURIComponent(now)}&to=${encodeURIComponent(in14d)}`);
const slots = await resp.json(); // [{ start, end }, ...]
// group slots by calendar date
const groups = {};
slots.forEach(slot => {
const key = new Date(slot.start).toLocaleDateString("zh-CN", { timeZone: TZ });
(groups[key] = groups[key] || []).push(slot);
});
// render date headers + time ranges
Object.entries(groups).forEach(([date, daySlots]) => {
renderDateHeader(date);
daySlots.forEach(slot => renderSlot(slot.start, slot.end));
});
}
fetchSlots();
})();
/api/slots returns a flat array of { start, end } ISO timestamps. The widget handles all the timezone formatting client-side — the API stays timezone-agnostic.
Other Static Site Generators#
The pattern is the same for Jekyll, 11ty, Astro, etc. — the widget is plain HTML + JS with no build step. The only difference is how each SSG handles raw HTML blocks:
| SSG | How to embed raw HTML |
|---|---|
| Hugo | rawhtml shortcode (shown above) |
| Jekyll | {% raw %} tag or .html include |
| 11ty | Nunjucks safe filter or .njk template |
| Astro | <Fragment set:html={...}> or .astro component |
Running It in Production (K3s + Homelab)#
Timeslot runs at slot.meirong.dev on my Oracle Cloud K3s cluster. The full stack:
| Layer | Tool |
|---|---|
| Cluster | K3s on Oracle Cloud (ARM64, A1.Flex) |
| Ingress | Traefik via Gateway API (HTTPRoute) |
| Tunnel | Cloudflare Tunnel — no open ports |
| Auth | HTTP Basic Auth (built-in); /api/* is public |
| Secrets | HashiCorp Vault — read at deploy time |
| Monitoring | Uptime Kuma + OTel Collector → Grafana |
Hybrid GitOps Deployment#
Git (HTTPRoute, DNS, Homepage) ──→ kubectl apply -k ──→ cluster state
Vault (admin_password) ──→ just deploy-timeslot ──→ helm upgrade
What lives in Git (declarative, auditable): HTTPRoute, Cloudflare Terraform, Homepage entry, Uptime Kuma monitor.
What stays outside Git (intentional): admin_password → Vault secret/oracle-k3s/timeslot.
just deploy-timeslot # fetches chart, injects secret from Vault, runs helm upgrade
just deploy-manifests # applies HTTPRoute + Homepage entry via Kustomize
cd cloudflare && just apply # creates slot.meirong.dev DNS record
Full infra code: meirongdev/homelab
Helm Chart: Lessons Learned#
Deploying into a real cluster surfaced four issues in the upstream chart. Quick summary:
1. Liveness probe hits /admin/ → CrashLoopBackOff
Kubernetes probes don’t send Basic Auth credentials. The pod restarts every 3 failures. Fix: default the probe to /api/slots (public endpoint) and make it configurable in values.yaml.
2. No existingSecret support
The chart always creates a Secret — this conflicts with external secret managers (Vault Agent, ESO) that create the same Secret name. Fix: add an existingSecret value to skip chart-managed Secret creation.
3. resources block misplaced in values.yaml
limits and requests are nested under service: instead of a top-level resources: key, so no resource limits are ever applied. Fix: move them to the correct top-level key.
4. sed delimiter breaks on passwords containing |
The init container uses | as the sed delimiter — a password with | causes a parse error. Better fix: mount the Secret as a file and read it at startup, removing the init container entirely.
All four are filed as PRs upstream. Adding a /healthz endpoint (lightweight, no SQLite query) is also on the list — using /api/slots for every readiness probe is unnecessarily expensive.
Roadmap#
- Multi-calendar aggregation
- Pre-built widgets for Hugo, Jekyll, 11ty
- Rate limiting on the public API
- Helm chart fixes merged upstream