System Architecture

How the Hebrew Calendar ad platform fits together — from iOS/Android apps requesting ads, through the Node server and SQLite database, to backups in DigitalOcean Spaces.

The big picture

High-level diagram

Three actors: the mobile apps (where ads are displayed), the ad server (decisions + state), and DigitalOcean (where everything is hosted). The customer dashboards and admin panel are just web views talking to the same server.

iOS APP Hebrew Calendar Objective-C + Swift SelfServedAdManager ANDROID APP Hebrew Calendar Java / Kotlin SelfServedAdManager CUSTOMER Live Dashboard Browser /r/dashboard/<token> HAGGAI (ADMIN) Admin Panel Browser /admin/ GET /v1/ad/next POST /v1/event/* /v1/admin/* AD SERVER Node.js + Express DigitalOcean App Platform · Frankfurt · ~$10/mo ads.js Ad serving + events admin.js CRUD + stats dashboard.js Customer access read / write backup / serve DATABASE SQLite better-sqlite3 · WAL mode 10 tables · ~5 MB · in-server OBJECT STORAGE + CDN DO Spaces hebrew-calendar-ads (AMS3) DB backups · ad creatives · landing pages hourly backup

Key insight: the whole system is intentionally small. One server, one database, one storage bucket. No microservices, no queue, no Redis. This is right-sized for the current load (~157K MAU, <100 ad requests/sec peak) and lets one developer maintain it.

Components

Tech stack

Ad Server
Node.js 20 + Express
DigitalOcean App Platform basic-XXS · ~$5/mo
Database
SQLite
better-sqlite3, WAL mode · in-server FS · backup flag
Object Storage
DO Spaces (AMS3)
DB backups, ad creatives, landing pages, logos
CDN
DO Spaces CDN
Same bucket served via cdn.digitaloceanspaces.com
iOS SDK
Objective-C + Swift
Full-screen interstitial, NSCache, 7-lang skip buttons
Android SDK
Java / Kotlin
Full-screen interstitial, LruCache, 7-lang skip buttons
Admin UI
Vanilla HTML/JS
Single file, Heebo font, RTL Hebrew, Chart.js for graphs
Customer UI
Vanilla HTML/JS
Token-based access, no login, navy + gold theme
Notifications
Telegram Bot API
Real-time alerts for new leads, WhatsApp clicks
Total Infra Cost
~$10 / month
$5 server + $5 Spaces (250GB) + free CDN
How it works

Three core flows

1. Showing an ad

When the iOS or Android app needs to show an interstitial, the SDK calls GET /v1/ad/next with the user's country, platform, language, and device ID. The server:

  1. Queries all campaigns where is_active=1, date range matches today, max impressions not exceeded, targeting matches the request
  2. If any eligible campaign has is_solo=1, narrows the pool to just solos (Solo Lock)
  3. Performs weighted random selection — campaign with weight=10 gets 10× the share of weight=1
  4. Returns the campaign's image URL, click URL, skip-after-seconds, and duration
  5. Sets Cache-Control: no-store to prevent iOS URLCache from returning stale ads

Typical response time: 20-40ms. Most of the latency is the SQL query plus network round-trip.

2. Recording an event

When the user sees, taps, or skips an ad, the SDK fires POST /v1/event/{impression|click|close} with the campaign_id, device_id, user country, and platform. The server:

  1. Validates the event (campaign exists, click matches a recent impression for the same device — bot detection)
  2. Inserts into impressions, clicks, or closes table
  3. For clicks: also bumps the campaign's current_impressions counter so cap enforcement works
  4. Triggers a debounced backup to Spaces (every write schedules a backup ~30s later if no other writes intervene)
  5. Returns {ok: true} with status 204

3. Customer dashboard access

Customer opens https://ads.hebcal.co.il/r/dashboard/<token>. The server:

  1. Looks up the token in campaign_access_tokens table (must have is_active=1)
  2. Resolves to the linked campaign_id
  3. Serves dashboard.html with the campaign_id embedded
  4. The page fetches /r/dashboard/<token>/data for live numbers (refreshes every 60s)
  5. No login, no password — the token IS the credential. If leaked, deactivate via admin.
Database

SQLite schema

10 tables. SQLite WAL mode for concurrent reads during writes. Foreign keys enforced. Backed up to Spaces every hour and after every write (debounced).

advertisers ~5 rows

The companies that run campaigns. Currently: 3 paying customers (Sirocco, Graphoholic) + in-house promo campaigns.

id          INTEGER PRIMARY KEY AUTOINCREMENT
name        TEXT NOT NULL
email       TEXT
notes       TEXT
created_at  TEXT DEFAULT CURRENT_TIMESTAMP

campaigns ~10 rows

Each ad campaign with targeting, weighting, Solo Lock, pricing model, and A/B test grouping.

id                   INTEGER PRIMARY KEY AUTOINCREMENT
advertiser_id        INTEGER NOT NULL REFERENCES advertisers(id)
name                 TEXT NOT NULL
image_url            TEXT NOT NULL
click_url            TEXT NOT NULL
target_countries     TEXT       -- JSON array: ["IL", "US"]
target_platforms     TEXT       -- JSON array: ["ios", "android"]
target_languages     TEXT       -- JSON array: ["he", "en"]
start_date           TEXT       -- YYYY-MM-DD
end_date             TEXT       -- YYYY-MM-DD
max_impressions      INTEGER    -- 0 = unlimited
current_impressions  INTEGER DEFAULT 0
skip_after_seconds   INTEGER DEFAULT 3
duration_seconds     INTEGER DEFAULT 5
weight               INTEGER DEFAULT 1     -- Rotation share
is_solo              INTEGER DEFAULT 0     -- Solo Lock flag
pricing_model        TEXT DEFAULT 'flat'   -- 'flat' | 'cpm' | 'cpc'
rate_agorot          INTEGER DEFAULT 0     -- CPM per 1000 imp / CPC per click
budget_total_agorot  INTEGER DEFAULT 0     -- 0 = unlimited
budget_spent_agorot  INTEGER DEFAULT 0     -- Auto-paused when ≥ total
ab_test_id           TEXT                  -- Groups A+B variants
ab_variant           TEXT                  -- 'A' | 'B' | NULL
is_active            INTEGER DEFAULT 0
created_at           TEXT DEFAULT CURRENT_TIMESTAMP

impressions growing

Every ad shown. The fact table for all analytics. Country + city derived from IP via geoip-lite.

id            INTEGER PRIMARY KEY AUTOINCREMENT
campaign_id   INTEGER NOT NULL REFERENCES campaigns(id)
device_id     TEXT
country       TEXT       -- ISO 3166 alpha-2 (IP-derived)
city          TEXT       -- IP-derived city name
platform      TEXT       -- 'ios' or 'android'
language      TEXT
created_at    TEXT DEFAULT CURRENT_TIMESTAMP

clicks growing

Every ad click. Joined to impressions for CTR.

id            INTEGER PRIMARY KEY AUTOINCREMENT
campaign_id   INTEGER NOT NULL REFERENCES campaigns(id)
device_id     TEXT
country       TEXT       -- IP-derived
city          TEXT       -- IP-derived
platform      TEXT
created_at    TEXT DEFAULT CURRENT_TIMESTAMP

leads ~50 rows

Inbound prospect inquiries from advertise.html / pricing.html. Has soft-delete + spam-flagging fields.

id              INTEGER PRIMARY KEY AUTOINCREMENT
name            TEXT
email           TEXT
phone           TEXT
company         TEXT
message         TEXT
source          TEXT       -- 'pricing' | 'advertise' | 'whatsapp'
campaign_id     INTEGER REFERENCES campaigns(id)
landing_page    TEXT
is_valid        INTEGER DEFAULT 1   -- spam flag
invalid_reason  TEXT
is_deleted      INTEGER DEFAULT 0
created_at      TEXT DEFAULT CURRENT_TIMESTAMP

whatsapp_clicks growing

When a user taps a WhatsApp CTA on the landing pages. Separate from ad clicks.

id            INTEGER PRIMARY KEY AUTOINCREMENT
landing_page  TEXT
position      TEXT       -- which button on the page (hero, footer, etc.)
device_id     TEXT
created_at    TEXT DEFAULT CURRENT_TIMESTAMP

campaign_access_tokens ~5 rows

Per-campaign access tokens for customer dashboards. The token IS the credential.

id          INTEGER PRIMARY KEY AUTOINCREMENT
campaign_id INTEGER NOT NULL REFERENCES campaigns(id)
token       TEXT NOT NULL UNIQUE      -- e.g., 'isr-7f3a9b2c'
label       TEXT                       -- internal identifier
is_active   INTEGER DEFAULT 1
created_at  TEXT DEFAULT CURRENT_TIMESTAMP
last_used   TEXT
view_count  INTEGER DEFAULT 0
API surface

Endpoints

Every route the server exposes. Three audiences: apps (no auth, public), customers (token-based), admin (header auth).

For mobile apps · No auth

GET
/v1/ad/next
Return next ad to show. Params: country, platform, language, device_id.
POST
/v1/event/impression
Record an impression. Body: campaign_id, device_id, country, platform.
POST
/v1/event/click
Record a click. Bot detection: rejects clicks <500ms after impression.
POST
/v1/event/close
Record a manual skip / close.
POST
/v1/event/whatsapp_click
Record a WhatsApp CTA tap on a landing page.
POST
/v1/lead
Submit a lead form (contact form on landing pages).

For customers · Token in URL

GET
/r/dashboard/:token
Render the customer dashboard HTML page.
GET
/r/dashboard/:token/data
JSON with the latest stats. Polled every 60s by the page.

For admin · X-Admin-Key header

GET
/v1/admin/campaigns
List all campaigns with their stats.
POST
/v1/admin/campaigns
Create a new campaign.
PUT
/v1/admin/campaigns/:id
Update campaign (any field, including weight and is_solo).
DELETE
/v1/admin/campaigns/:id
Delete a campaign + cascading delete of related impressions/clicks.
GET
/v1/admin/advertisers
List all advertisers.
GET
/v1/admin/leads
List leads. Param: include_deleted=1.
GET
/v1/admin/reports/data
Aggregated report data (JSON). Params: advertiser_id, campaign_id, from, to.
GET
/v1/admin/reports/csv
Same as above but CSV format for spreadsheet export.
POST
/v1/admin/tokens
Create a customer access token for a campaign.
POST
/v1/upload/image
Upload ad creative directly to Spaces CDN; returns the public URL.
GET
/v1/admin/settings/backup-status
When was the last successful backup; is the flag file in place.
Repository

File structure

Local repo: ~/hebrew-calendar-ad-server. Single main branch. Push to Bitbucket → DigitalOcean auto-deploys (~2 min).

hebrew-calendar-ad-server/
├── src/
│   ├── db/
│   │   └── index.js            # Schema, migrations, backup/restore
│   ├── index.js                # Express setup, route registration
│   ├── routes/
│   │   ├── ads.js              # /v1/ad/next + event endpoints
│   │   ├── admin.js            # All /v1/admin/* routes
│   │   ├── dashboard.js        # Public customer dashboards /r/*
│   │   ├── events.js           # whatsapp_click, lead form
│   │   ├── reports.js          # JSON + CSV reports
│   │   └── upload.js           # Image upload to Spaces
│   └── lib/
│       ├── spaces.js           # DO Spaces SDK wrapper
│       └── notify.js           # Telegram notification helpers
├── admin/
│   └── index.html              # Single-file admin (~2200 lines)
├── public/
│   ├── pricing.html            # Bilingual sales page
│   ├── graphoholic.html        # Landing for Graphoholic campaign
│   └── ad-spec.html            # Design requirements (designers)
├── data/
│   ├── ads.db                  # SQLite database
│   └── backup-expected.flag    # Safety: crash on startup if backup missing
├── package.json
├── .env                        # Secrets (NOT committed)
└── README.md
Deployment

Deploy pipeline

From commit to production · ~2 minutes

  1. Local commit via Sourcetree to main branch
  2. Push to Bitbucket — single remote, no PR flow
  3. DigitalOcean webhook picks up the push
  4. App Platform builds a new container (~90s)
  5. Health check passes (GET /health returns 200)
  6. Zero-downtime swap from old container to new
  7. On startup: DB checks for backup flag; if missing, crashes intentionally
  8. If backup flag present: restores latest backup from Spaces, applies migrations, starts serving

Safety: the backup flag

Earlier in development, a deploy bug wiped the database on every push. The fix: a flag file at data/backup-expected.flag. On startup, if the flag exists but the local DB doesn't, the server crashes intentionally instead of starting with an empty DB. This forces manual restore from Spaces backups.

Without this safety, any storage volume issue would silently wipe customer data. Worth the operational annoyance.

Why this way

Architectural decisions

SQLite instead of Postgres

Single-server architecture. SQLite is faster (no network hop), simpler (one file), and handles >10K req/sec for read-heavy workloads. Migration plan exists: when concurrent writes become a bottleneck (~300K DAU) → migrate to Postgres.

Weighted random in JS, not SQL

SQLite has no WEIGHTED_RANDOM(). SQL workarounds are obscure and slow. Doing it in JS lets us add frequency capping, pacing, and inventory forecasting on the same in-memory list later.

Token-based dashboards instead of login

Customer dashboards need to be easy. Passwords get forgotten, support tickets pile up. Token-in-URL is the same model Calendly uses: one tap, instant access. If a token leaks, deactivate from admin.

Debounced backups, not transactional

Every write to the DB schedules a backup to Spaces ~30s later. If another write comes in, the schedule resets. This batches bursts (10 events in 5s → 1 backup) and stays within DO Spaces bandwidth limits while keeping recovery point ≤30s.

Vanilla HTML/JS, no framework

Admin and customer dashboards are single HTML files with embedded JS. No React, no build step, no node_modules. Anyone with a browser dev tools can debug the UI. Pages load fast (cold start ~100ms). When a feature actually needs reactive state (rare), we add Alpine.js or similar — but not preemptively.

Quick reference

Production URLs

ServiceURL
Ad server (DO direct) hebrew-calendar-ad-server-w4yu2.ondigitalocean.app
Ad server (custom domain) ads.hebcal.co.il
Admin dashboard ads.hebcal.co.il/admin/
Customer dashboard ads.hebcal.co.il/r/dashboard/<token>
Spaces bucket (origin) hebrew-calendar-ads.ams3.digitaloceanspaces.com
Spaces CDN hebrew-calendar-ads.ams3.cdn.digitaloceanspaces.com
Repository Bitbucket: hebrew-calendar-ad-server