Wellness Booking Platform
Full-stack booking platform for a Calgary wellness business — 9 production releases, 5-container Docker stack, independent A- security audit, React admin SPA, and Twilio SMS. Live in production; client details anonymized.
What It Is
Client identity, business name, and live URL are withheld at the client’s request. Everything below — architecture, code patterns, security posture, and the version history — reflects exactly what was designed, built, and shipped.
This is a production booking platform built for a Calgary-based wellness business (lymphatic drainage and body contouring). The client — a solo practitioner — needed to move off manual DMs and run their entire online presence without ever touching code. The result: a full-stack system spanning a public booking site, a React admin panel, an API backend, and automated email + SMS confirmations, shipped across 9 production releases from February to April 2026.
Infrastructure: Hetzner CPX22 (2 vCPU, 4GB RAM, Ubuntu 24.04) behind Cloudflare CDN/WAF. Total cost: ~$13/mo.
Stack Summary
| Layer | Technology |
|---|---|
| Backend API | Django 4.2, DRF, Gunicorn |
| Database | PostgreSQL 15 |
| Cache / Rate Limiting | Redis 7 |
| Auth | Custom ExpiringTokenAuthentication (24-hour TTL + rotation) |
| django-anymail (Resend → SendGrid → Gmail, pluggable via env) | |
| SMS | Twilio |
| Public Frontend | Vanilla HTML/CSS/JS — no build step, instant loads |
| Admin Panel | React 19 + Vite — 10 components |
| Containerization | Docker Compose — 5 services |
| CDN / WAF | Cloudflare (DDoS, bot fight, edge caching) |
| Scheduling | Docker sidecar + management command (every 5 min) |
| CI | GitHub Actions (lint + pytest + PostgreSQL service container) |
| Error Tracking | Sentry (PII excluded) |
| Dependency Updates | Dependabot (pip, npm, docker, github-actions) |
Architecture Decisions
Vanilla HTML for the public site. The booking flow and landing page are pure HTML/CSS/JS — no framework, no build step. Loads instantly. SEO-indexable without any SSR configuration.
React for the admin. The admin is a stateful application with 7 tabs, controlled forms, rich-text editing, image management, and API polling. Ten focused components, each independently testable. A monolith admin.html wouldn’t scale past the first three features without becoming unmanageable.
Service layer for business logic. bookings/services/booking.py contains create/cancel/reschedule logic — not views.py. Views handle HTTP; the service layer handles business rules. The same double-booking prevention path runs whether the request comes from a client or from the admin panel.
Background threads for notifications. Email and SMS dispatch runs on daemon threads, so Twilio/SMTP latency never blocks the client-facing API response. If Twilio is down, the booking still confirms — the notification failure logs to Sentry.
The Hard Engineering Problems
Double-Booking Prevention
The core race condition: two clients hit “Book” within milliseconds of each other on the same slot. A check-then-insert pattern fails silently under concurrent load.
The fix is SELECT FOR UPDATE inside an atomic transaction, with a service-row sentinel for the empty-day edge case:
with transaction.atomic():
# Lock the service row as a sentinel — without this, two concurrent
# requests for an empty day both pass the overlap check (zero rows to lock).
Service.objects.select_for_update().get(pk=service.pk)
overlapping = Appointment.objects.select_for_update().filter(
status__in=['confirmed', 'pending_payment'],
start_time_utc__lt=end_time_utc,
end_time_utc__gt=start_time_utc,
)
if overlapping.exists():
raise SlotTakenError('This slot was just taken. Please choose another time.')
appointment = Appointment.objects.create(...)
The sentinel lock on the Service row is the non-obvious part. Without it, two requests for the first slot on an empty day both execute select_for_update on zero rows — there’s nothing to lock — and both pass the overlap check. Locking the parent row serializes them.
This same locking path covers all three entry points: client booking, admin booking, and admin reschedule.
Docker Network Boundary
Standard Docker Compose with ports: "8000:8000" on the backend has a subtle security flaw: Docker’s iptables rules bypass ufw. The Django API was reachable on the origin IP from the public internet, even with ufw allow 22/80/443 only, because Docker writes iptables rules directly rather than through ufw’s chain.
The fix in v1.4: remove all ports: blocks from backend, Redis, and PostgreSQL containers. The frontend container binds to 127.0.0.1:5500 only, so it’s unreachable from the internet even if the origin IP leaks past Cloudflare. Only the host Nginx can reach any container.
# docker-compose.yml — v1.4 and later
frontend:
ports:
- "127.0.0.1:5500:80" # Only host Nginx can reach this
# backend, redis, db: no ports: block at all
This was caught in the v1.4 security audit and fixed before launch.
Email Sender Reputation Isolation
Transactional booking confirmations originally sent from the business’s root domain. Any spam complaint on a booking email threatened the practitioner’s personal reply domain too. The fix mirrors Stripe and Airbnb’s email architecture:
- Transactional mail sends from a dedicated sending subdomain (e.g.
bookings@mail.example.com) - A
Reply-Toheader pointing at the practitioner’s real inbox on the root domain (e.g.owner@example.com) routes client replies correctly - The root domain’s reputation is isolated from booking spam complaints
This required wrapping Django’s send_mail in a send_transactional() helper using EmailMultiAlternatives, which supports custom headers. Django’s basic send_mail() doesn’t accept headers.
Security: Independent A- Audit
An independent code audit rated the application A- overall — comparable to mid-market SaaS booking platforms (Acuity, Jane App) and significantly above what’s typical for custom-built small business sites.
OWASP Top 10 coverage (8 of 9 applicable categories rated Strong/Good):
| Category | Rating |
|---|---|
| Broken Access Control | Strong — IDOR protection, email verification, stripped serializers |
| Cryptographic Failures | Good — HTTPS enforced, HSTS preload, tokens via secrets module |
| Injection | Strong — bleach sanitization, Django ORM (zero raw SQL), CSP |
| Insecure Design | Good — SELECT FOR UPDATE locking, consent mechanism, race condition fix |
| Security Misconfiguration | Strong — production safety guard, no debug in prod, explicit CORS |
| Vulnerable Components | Strong — Dependabot weekly PRs for pip, npm, docker, github-actions |
| Authentication Failures | Strong — expiring tokens, brute-force protection, inactivity timeout |
| Data Integrity Failures | Good — bleach sanitization, magic byte validation on uploads |
| Logging & Monitoring | Moderate — structured JSON logs + Sentry, but no external alerting pipeline |
Three-layer rate limiting: Nginx limit_req_zone (edge) → Redis-backed django-ratelimit (application) → per-view limits on sensitive endpoints.
Token auth with 24-hour expiry: A custom ExpiringTokenAuthentication class deletes old tokens on login and issues a fresh one. Old tokens can’t be replayed. Combined with a 15-minute client-side inactivity timeout.
File upload security: Extension check + file size limit + magic byte validation. Three independent layers.
The Admin Panel
The client runs their entire business from the admin panel without touching code. It’s a 10-component React 19 + Vite SPA:
- Overview — live stats + today’s schedule, auto-refreshes every 60 seconds
- Bookings — filterable table with modal (desktop) or card list (mobile) + full booking management
- Revenue — DB-level aggregation, payment history
- Services — full CRUD for treatment types, pricing, duration
- Blog — rich-text editor, draft/published/scheduled statuses, cover image upload
- Gallery — upload, reorder (▲/▼), caption, visibility toggle
- Block Dates — single-day and partial-day blocking + recurring weekday closures (chip UI)
The mobile rebuild in v1.5 was substantial: drawer sidebar, card-list bookings, full-viewport modals, 44px touch targets throughout, and 16px form inputs to prevent iOS Safari’s auto-zoom.
Privacy Compliance (PIPEDA/PIPA)
Canadian privacy law governs the client data. Full implementation:
- Explicit consent checkbox at booking, enforced server-side, timestamped
anonymize_old_data.pymanagement command scrubs PII older than 2 years (with--dry-runmode)- Public serializer strips email and phone — cancel/reschedule require email match
- Sentry configured with
send_default_pii=False, request body stripped inbefore_send - No analytics, no cookies, no third-party scripts on the public frontend
- Privacy policy linked directly from the consent checkbox
Testing and CI
62 pytest tests across 5 files:
test_slots.py— slot calculator, 30-minute grid, buffer logictest_booking.py— booking lifecycle, concurrency, cancellationtest_conflicts.py— blocked date conflict detectiontest_revenue.py— aggregation consistencytest_admin.py— admin CRUD, XSS sanitization, IDOR protection, PIPEDA consent
GitHub Actions runs lint (ruff) + full test suite with a PostgreSQL service container on every push and PR. Dependabot sends weekly PRs for all dependency ecosystems.
Version History (9 Releases)
| Version | Headline |
|---|---|
| v1.0 | Initial release — landing page, basic booking, Stripe (later removed) |
| v1.1 | API-first architecture overhaul — DRF, Docker, PostgreSQL |
| v1.2 | Blog, gallery, revenue tracking, client reschedule |
| v1.3 | React admin rebuild, SELECT FOR UPDATE locking, 62-test suite, CI |
| v1.4 | Pre-launch hardening — Docker network fix, Dependabot, business hours API, find-my-booking |
| v1.5 | Admin mobile rebuild (iPhone-first), recurring weekday closures |
| v1.6 | Google Calendar button, branded email addresses |
| v1.7 | Email sender-reputation isolation (subdomain + Reply-To) |
| v1.8 | Repository hygiene — .gitignore, .dockerignore, dev .env removed from image |
| v1.9 | Gallery rendering fix — Nginx media location block, 7-day cache headers |
What I Learned
Docker’s iptables rules bypass ufw. This isn’t obvious from the documentation and is a real production security issue, not a theoretical one. Any published Docker port is reachable on the origin IP regardless of firewall rules unless you bind to loopback.
Business logic belongs in a service layer, not views. The same create/cancel/reschedule logic needs to be callable from the client API, the admin panel, and management commands. Putting it in views.py guarantees duplication or coupling.
Sender reputation is a separate concern from deliverability. Moving transactional mail to a subdomain and adding Reply-To routing is a two-line config change with meaningful production consequences — the kind of thing that typically gets discovered after a Gmail blacklisting, not before.
Nine versions is not nine do-overs. Each version was a production deploy to a live system. Iterating without breaking the live client required disciplined database migrations, backward-compatible API changes, and a test suite that caught regressions before they shipped.