A distributed voting system for the Eurovision Song Contest, featuring a modern web frontend, a Go REST + gRPC backend, real-time vote streaming, a full observability stack with tracing, metrics, and log aggregation β all served through a Caddy HTTPS reverse proxy.
βββββββββββββββββββββββββββββββ
β Caddy β
β HTTPS reverse proxy β
β Ports 80 (β443) + 443 β
β tls internal (local CA) β
ββββββββββββββββ¬βββββββββββββββ
β routes by subpath
βββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββ
β / β /crud-api/ /eurostats/ β
βΌ βΌ βΌ β
βββββββββββββββββββββββ ββββββββββββββββββββββ ββββββββββββββββ β
β Frontend β β CRUD DB API (Go) β β EuroStats β β
β Node.js/Express β β REST + gRPC β β FastAPI β β
β Port 3001 β β Port 8000 / 50051 β β Port 8880 β β
ββββββββββββ¬βββββββββββ ββββββββββββ¬ββββββββββ ββββββββ¬ββββββββ β
β β SQL β² gRPC β β
β βΌ β β β
β ββββββββββββββββββββ β β
β β MySQL 8.0 β βββββββββββββββββββ-β β
β β Port 3306 β βPublicVoteConverterβ β
β ββββββββββββββββββββ β Go, Port 8090 β β
β βββββββββββββββββββ-β β
β β β
β /grafana /prometheus /tempo /loki β β
βββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βΌ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β Observability Stack β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β
β ββββββββββββββββββββ ββββββββββββββ βββββββββββ βββββββββββ β β
β β OTel Collector β β Prometheus β β Tempo β β Loki β β β
β β 4317 / 4318 βββΊβ 9090 β β 3200 β β 3100 β β β
β ββββββββββββββββββββ βββββββ¬βββββββ ββββββ¬βββββ ββββββ¬βββββ β β
β ββββββββββββββββΌββββββββββββββ β β
β βΌ β β
β βββββββββββββββ β β
β β Grafana β β β
β β 3000 β β β
β βββββββββββββββ β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Start Contest button in the admin panel fetches all songs from the database, shuffles them into a random order using a Fisher-Yates shuffle, and persists the result as an active
Contest_Runrow. - Running Now page (
/now) shows the currently performing song with a full-width YouTube embed, song and artist details, a live score counter, and a contest progress bar. - Inline voting panel β viewers cast points directly below the video without leaving the page. Includes a point stepper (+/β), phone number input, and country selector.
- Auto-advance β the admin clicks "Next Song" to move to the next entry; all viewers on
/nowdetect the change within 5 seconds and reload automatically. - Contest-end banner β when the last song finishes, a banner appears linking to the results page.
- YouTube URL normalization β any YouTube URL format (watch, share, short, embed) is automatically converted to the correct embed format before storage and rendering.
- HTTPS everywhere β Caddy is the sole external entry point; all services are unreachable directly from outside Docker. TLS is provided by Caddy's built-in local CA (
tls internal). - Multi-tier Authentication β Admin, Jury, and Public user roles
- bcrypt Password Hashing β Secure credential storage and token validation
- Phone Number Verification β One vote per phone number (bcrypt-hashed in DB)
- Cookie-based Vote Tracking β Prevents duplicate voting from the same browser
- Token Replay Prevention β Used tokens are tracked in-memory to block reuse
- Rate Limiting β Per-IP token-bucket rate limiting on every endpoint
- Non-root containers β CRUD API runs in a minimal distroless image; EuroStats runs as a dedicated
appuser(UID 1001)
- gRPC VoteService β The CRUD API exposes a
StreamVotesserver-side streaming RPC on port50051 - Historical + Live β New subscribers optionally receive all current vote totals before live updates
- EuroStats Consumer β FastAPI microservice that connects as a gRPC client and re-exposes votes via REST and WebSocket
- Distributed Tracing β Full request tracing with OpenTelemetry, stored in Tempo
- Metrics Collection β Prometheus metrics for request duration, size, counts, backend call latency, vote counts, and active sessions
- Structured Logging β JSON-formatted logs from all services, aggregated in Loki
- Grafana Dashboards β Auto-provisioned data sources (Prometheus, Loki, Tempo) with trace-to-log correlation
- Gunicorn Multiprocess Metrics β Prometheus multiprocess mode ensures correct metric aggregation across all frontend workers
- Public Voting β Phone number verification, duplicate prevention via cookie + hashed phone registry
- Jury Voting β Authenticated jury votes with configurable point values (Eurovision-style 1β8, 10, 12)
- Vote Status Control β Admin open/close toggle; all endpoints respect the global voting state
- Multi-country Support β Voters cannot vote for their own country
- Real-time Results β Live results page polling every 10 s; WebSocket stream available via EuroStats
| Service | Technology | Internal Port | README |
|---|---|---|---|
| Caddy | caddy:2.8-alpine |
80, 443 | backend/Caddy/README.md |
| Frontend | Node.js 22, Express.js, Tailwind CSS | 3001 | esc-frontend/README.md |
| CRUD DB API | Go 1.24, net/http, gRPC, Prometheus, OTel | 8000, 50051 | backend/CRUD-DB-API/README.md |
| PublicVoteConverter | Go 1.24, net/http, gRPC client, Prometheus, OTel | 8090 | backend/PublicVoteConverter/README.md |
| EuroStats | Python 3.11, FastAPI, gRPC, OTel | 8880 | backend/EuroStats/README.md |
| MySQL | MySQL 8.0 | 3306 | backend/DB/README.md |
| EuroMail | Node.js, Express, Resend API | 3000 (internal) | - |
| Observability | OTel Collector, Prometheus, Grafana, Loki, Tempo | 4317β4318, 9090, 3000, 3100, 3200 | backend/Observability/README.md |
All services are accessed through Caddy on a single HTTPS host. Replace <host> with the IP address or hostname of the machine running Docker.
| Service | URL |
|---|---|
| Frontend β Vote | https://<host>/ |
| Frontend β Running Now | https://<host>/now |
| Frontend β Live Results | https://<host>/results |
| Frontend β Admin Dashboard | https://<host>/admin |
| Frontend β Jury Voting | https://<host>/jury |
| CRUD DB API (direct access) | https://<host>/crud-api/ |
| PublicVoteConverter (direct access) | https://<host>/esc-converter/ |
| EuroStats | https://<host>/eurostats/ |
| Grafana | https://<host>/grafana/ |
| Prometheus | https://<host>/prometheus/ |
| Tempo | https://<host>/tempo/ |
| Loki | https://<host>/loki/ |
Note: Plain HTTP (
http://<host>) redirects automatically to HTTPS.
To expose the ESC Voting System to the internet via escvoting.dev, use Cloudflare Tunnel:
Setup:
- Create a Cloudflare account and add your domain to Cloudflare DNS
- In the Cloudflare dashboard, go to Zero Trust β Tunnels and create a new tunnel named
escvoting - Copy the tunnel token and add it to your
.env:CLOUDFLARE_SECRET=<your-token-from-cloudflare-dashboard>
- Start Docker Compose β the
cloudflaredservice will automatically connect to your tunnel
Public Routes (via escvoting.dev):
- Frontend:
https://escvoting.dev/ - Grafana:
https://escvoting.dev/grafana/
Security: Only the frontend and Grafana are exposed to the internet. All other services (database, APIs, Prometheus, Tempo, Loki) remain internal and are unreachable from outside the Docker network.
TLS: Cloudflare manages the public HTTPS certificate at the edge. Internally, services communicate via Caddy's self-signed certificates.
Caddy uses its built-in local CA to issue a self-signed certificate. Your browser will show a security warning until you trust the CA cert. To install it:
# Copy the CA cert out of the running Caddy container
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-local-ca.crt
# macOS
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain ./caddy-local-ca.crt
# Linux (Debian/Ubuntu)
sudo cp caddy-local-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# Windows: double-click caddy-local-ca.crt β
# Install Certificate β Local Machine β
# Trusted Root Certification AuthoritiesFirefox maintains its own trust store β go to Settings β Privacy & Security β Certificates β View Certificates β Authorities β Import.
Username: admin
Password: admin
- Docker + Docker Compose v2
- A
.envfile in the project root (see below)
# MySQL
MYSQL_ROOT_PASSWORD=secretroot
# Admin / Jury tokens β change these before deploying
adminPassword=change-me-admin
juryPassword1=change-me-jury-1
juryPassword2=change-me-jury-2
juryPassword3=change-me-jury-3
# Frontend
SESSION_SECRET=change-me-session-secretdocker compose up -d --buildThen open https://<host>/ in your browser (accept or trust the certificate on first visit).
docker compose downTo also remove all persistent data volumes:
docker compose down -v| Table | Description |
|---|---|
Land |
Countries β ISO 2-letter ID (alpha-2), name, pot assignment |
Kuenstler |
Artists β solo, duo, or group; linked to a country |
Komponist |
Composers β first and last name |
Song |
Songs β linked to country and artist; stores public, jury, and computed total points; optional YouTube embed URL |
Song_Komponist |
Many-to-many relationship between songs and composers |
Voting_Status |
Single-row global flag controlling whether voting is open |
Phone_Nums |
Registry of bcrypt-hashed phone numbers that have already voted |
Contest_Run |
Active contest state β shuffled song order (JSON), current index, start timestamp, and active flag |
All API paths below are relative to the CRUD API's internal address (db-crud-api:8000). When accessed externally via Caddy, prefix them with /crud-api/ β e.g. https://<host>/crud-api/votes/.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/votes/ |
All songs ranked by total points |
GET |
/countries/ |
List all countries |
GET |
/countryByName/{NAME} |
Get a country by name |
GET |
/songs/ |
Full song list with artist, country, composers, and voting status |
GET |
/songByID/{ID} |
Single song detail by ID |
POST |
/vote/ |
Cast a public vote |
GET |
/contest/current |
Current song in the active contest run with full details and progress |
GET |
/metrics/ |
Prometheus metrics |
| Method | Path | Description |
|---|---|---|
GET |
/auth/requestToken |
Request an authentication token |
GET |
/auth/verifyToken/{token} |
Verify an authentication token |
POST |
/auth/login |
Login with credentials |
POST |
/auth/verify |
Verify authentication |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/admin/authenticate |
Admin token | Validate an admin token β 202 on success, 403 on failure |
POST |
/admin/open |
Admin token | Open voting |
POST |
/admin/close |
Admin token | Close voting |
DELETE |
/admin/deleteVotes/ |
Admin token | Reset all votes |
POST |
/admin/addCountry/ |
Admin token | Add a country |
POST |
/admin/addSong/ |
Admin token | Add a song (accepts optional YoutubeURL parameter) |
POST |
/admin/addArtist/ |
Admin token | Add an artist |
POST |
/admin/addInterpret/ |
Admin token | Add a composer |
POST |
/admin/startContest |
Admin token | Shuffle all songs and start a new contest run |
POST |
/admin/advanceContest |
Admin token | Advance to the next song in the active contest |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/jury/authenticate |
Jury token | Validate a jury token β 202 on success, 403 on failure |
POST |
/jury/vote/ |
Jury token | Cast a jury vote |
Accessible externally at https://<host>/eurostats/.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/votes/subscribe |
Poll current vote snapshot (up to 100 entries) |
WS |
/ws/votes |
WebSocket β real-time vote event stream |
WS |
/ws/stats |
WebSocket β real-time statistics update stream |
Services communicate over isolated Docker networks. No service other than Caddy has ports bound on the host.
| Network | Connected Services |
|---|---|
backend |
db, api, public-vote-converter, eurostats |
frontend |
api, public-vote-converter, frontend, caddy |
mail |
api, euromail |
observability |
api, public-vote-converter, frontend, eurostats, euromail, otel-collector, prometheus, grafana, loki, tempo, caddy |
CRUD API ββOTLP/HTTPβββΊ
Frontend ββOTLP/HTTPβββΊ OTel Collector βββΊ Tempo (traces)
EuroStats ββOTLP/gRPCβββΊ βββΊ Prometheus (metrics)
PublicVoteConverter ββOTLP/HTTPβββΊ βββΊ Loki (logs)
β
ββββΊ Grafana (unified view)
https://<host>/grafana/