kite spots database and weather forecast for kitesurfers on the web
see it online at: https://varun.surf
- infra: Docker, Docker Compose, Nginx, GitHub, GitHub Actions, GHCR, Cloudflare, SeoHost, Mikrus
- backend: Java, Spring Boot, Gradle
- frontend (bundled with the backend app): Vanilla JS, HTML, CSS, Bun
./gradlew build
./gradlew bootRun
unit testing:
./gradlew test
e2e testing:
./gradlew testE2e
e2e testing with visible browser:
./gradlew testE2eNoHeadless
docker build -t varun-surf .
docker run -p 8080:8080 varun-surf
./deployment.sh dev
for prod setup, check continuous delivery and zero-downtime deployment sections.
docker image is automatically deployed to the registry at ghcr.io via cd.yml GitHub action from the master branch
- configure PAT (Personal Access Token) here: https://github.com/settings/tokens
- set permissions:
write:packages,read:packages - remember, you need to refresh the token in the future, once it will become outdated
- copy your access token to the clipboard
now, login into docker registry:
PAT=YOUR_ACCESS_TOKEN
echo $PAT | docker login ghcr.io -u pwittchen --password-stdin
pull image and run the container:
docker pull ghcr.io/pwittchen/varun.surf
docker run -p 8080:8080 ghcr.io/pwittchen/varun.surf:latest
After each push to the master or PR, a new build is triggered with tests and test coverage report.
It's done automatically via the ci.yml GitHub action
After each tag push with v prefix, cd.yml GitHub action is triggered,
and this action deploys the latest version of the app to the VPS.
Deployment of the app is configured with the bash, docker, and docker compose scripts. With these scripts, we can perform zero-downtime (blue/green) deployment with nginx server as a proxy. To do that, follow the instructions below.
- Copy
deployment.sh,docker-compose.prod.yml,.env, and./nginx/nginx.conffiles to the single directory on the VPS. - In the
deployment.shanddocker-compose.prod.ymlfiles adjust server paths if needed - In the
.envfile, configure the environment variables basing on the.env.examplefile. - Run
./deployment.sh prodscript to deploy the app with the nginx proxy. - Run the same command again to perform the update with a zero-downtime and the latest docker image.
- If you want to test the deployment locally, run
./deployment.sh devscript. - To stop everything, run:
docker stop varun-app-blue-live varun-app-green-live varun-nginx
The app runs on a Mikrus VPS. Besides plain SSH, the server can be
administered through the mikrus MCP server, which
exposes the Mikrus API as tools for an MCP client like Claude. Read-only tools cover the
server info, list, stats, status, ports, logs, databases, cloud and profiles; the tools
that change the server - restart, exec, domain, amfetamina and switching the active
server - should always be confirmed before they are run. Credentials come from the
MIKRUS_SRV and MIKRUS_KEY environment variables or from ~/.mikrus, and the tools
act on the default server configured there, so it is worth checking which server is
selected before running anything.
The data is public and the repository is public with it, so none of this tries to stop anyone from reading the API. It stops the API from being abused - hammered, or used as a free backend by someone else's app.
- rate limiting (
nginx/nginx.conf): 10 r/s per IP across/api/v1/**with a burst of 30 (a page load fires several calls at once), and a stricter 1 r/s for the endpoints that cost real work or real bytes -/api/v1/wind,/llms/**,/mcp/**and/api/v1/logs, where the trickle is also what stops a brute force on the basic auth. The two on-demand generation endpoints (/api/v1/spots/{id}/analysisand/api/v1/spots/{id}/icm) get their own 20 r/m: they are the only requests that spend money, and at the general api rate one client could walk the whole spot list and run up the OpenAI bill in minutes. They also get a longer proxy timeout, since the visitor is waiting on a model. SSE connections are capped at 4 per client. Over the limit is a 429. - real client address: behind Cloudflare
$remote_addris the edge, so nginx trustsCF-Connecting-IPfrom the Cloudflare ranges listed in the config. Without this every visitor shares one rate-limit bucket. Refresh the ranges from cloudflare.com/ips when they change. Note this assumes traffic reaches nginx through Cloudflare: a client able to hit the origin port directly could set the header itself. - session cookie (
SessionAuthenticationFilter): a signed stateless token issued on any page visit and required by/api/v1/**. It costs a scraper one extra request and nothing more -/llms/**and/mcp/**serve the same data without it, by design. - fail-closed logs: with no
ANALYTICS_PASSWORDset,/api/v1/logsis denied rather than opened to everyone holding a cookie. Set the variable or the logs page stays dark. - security headers:
X-Content-Type-Options,Referrer-Policy,Permissions-Policyand HSTS are set by nginx. The CSP is deliberatelyContent-Security-Policy-Report-Only- the frontend carries inline styles and scripts, so enforcing it as-is would break pages. Watch the console for violations, tighten, then rename the header.
The strongest lever is not in this repository: Cloudflare's Bot Fight Mode and WAF rate limiting rules filter automated traffic at the edge, before it reaches the VPS at all.
Updated styles, scripts, and images are visible right after a deployment, without waiting for the Cloudflare edge cache or a browser cache to expire. This works on three levels:
- content-hashed assets: CSS, JS, and the logo are emitted as
/assets/<name>.<hash>.<ext>by the frontend build, spot photos get a?v=<content hash>suffix. A changed file means a changed URL. - cache headers (
CacheControlFilter): hashed assets areimmutablefor a year, HTML is always revalidated (so pages never keep pointing at stale asset URLs), unversioned files are cached for 5 minutes, API and actuator responses areno-store. - cache purge on deploy: when
CLOUDFLARE_ZONE_IDandCLOUDFLARE_API_TOKENare set in the.envfile,deployment.shpurges the Cloudflare cache once the new version is live. The API token needs the Zone β Cache Purge permission. Without these variables the purge step is skipped.
In Cloudflare, keep Browser Cache TTL set to Respect Existing Headers, otherwise the dashboard setting overrides the headers described above.
We can view system status, by visiting /status page. Data sources are listed on the /sources page.
We can enable application and JVM metrics in the application.yml file and then use /actuator/prometheus endpoint to view metrics.
The app includes a custom metrics dashboard at /metrics that displays:
- Application gauges: total spots, countries, active live stations, cache sizes, last fetch timestamps
- Fetch counters: forecast/conditions/AI fetch totals, successes, and failures
- API request counters: spots and single spot endpoint request counts
- Timers: forecast, conditions, and AI fetch durations (count, total time, mean, max)
- JVM metrics: heap/non-heap memory usage, thread counts, GC pause stats, CPU usage, uptime
- HTTP client metrics: active/total/success/failed requests, connection stats, DNS/connect durations
- Wide/narrow view toggle: expand to full width for better readability
The metrics dashboard needs no password: it is open to any visitor of the site.
The app includes a logs dashboard at /logs that displays:
- Real-time application logs with auto-refresh every 5 seconds
- Level filtering: filter by ERROR, WARN, INFO, DEBUG, or TRACE
- Text search: search through log messages, logger names, and thread names
- Wide/narrow view toggle: expand to full width for better readability
- In-memory buffer: stores the last 1000 log entries (oldest logs are evicted when buffer is full)
Note: Logs are stored in memory only and are lost on application restart.
Configuration:
The logs dashboard is the only password-protected one. Set your password in the .env file:
ANALYTICS_PASSWORD=your-secure-password
It's possible to enable AI/LLM in the app, so a spot can get an AI-generated comment on its forecast.
If you want to use AI in the app, configure OpenAI API key in the application.yml.
An exemplary docker command to run the app with enabled AI analysis:
docker run -p 8080:8080 varun-surf \
--app.feature.ai.forecast.analysis.enabled=true \
--spring.ai.openai.api-key=your-api-key-here
Nothing is generated in the background. The analysis is written only when someone presses "generate AI analysis" on a spot page, and then held for 24 hours - so a reload, or a second visitor on the same spot that day, costs nothing. The same applies to the ICM forecast, which is read off the meteogram image by a vision model from its own button.
NOTE: I added this feature as an experiment, but it does not really add any big value to this particular project, so I disabled it by default. It also used to run on a timer - a daily pass over every spot in two languages, plus an ICM meteogram reading every three hours for each Polish and Czech spot - which spent most of the budget on spots nobody opened that day. On-demand generation ties the cost to what people actually look at, and stops it growing with the spot list. A single analysis is roughly 2.2-2.5k input tokens on gpt-4o-mini; reading an ICM meteogram is far more expensive, because the image alone is ~25k tokens.
- Backend Architecture β see docs/BACKEND.md file
- Frontend Architecture β see docs/FRONTEND.md file
The project includes specialized Claude Code agents that can be triggered using shortcuts:
| Trigger | Agent | Purpose |
|---|---|---|
@new-kite-spot [location] |
kite-spot-creator | Research and add a new kite spot to spots.json |
@new-weather-station [url] |
weather-station-strategy | Create a new weather station integration strategy |
@debug-api [target] |
api-debugger | Diagnose issues with external APIs (Windguru, weather stations, maps) |
@e2e-test [feature] |
e2e-test-writer | Write E2E tests for features using Playwright |
@review [file/feature] |
code-reviewer | General code review for quality, bugs, and best practices |
@arch [topic] |
arch-analyzer | System architecture analysis, dependencies, and design patterns |
@security [target] |
security-auditor | Security vulnerability assessment and OWASP compliance |
@perf [target] |
perf-analyzer | Performance analysis for speed, memory, and resource optimization |
@async [target] |
async-reviewer | WebFlux/Reactor patterns, Virtual Threads, and concurrency review |
Examples:
@new-kite-spot Tarifa, Spain
@new-weather-station https://holfuy.com/en/weather/1234
@debug-api windguru spot 48009
@e2e-test favorites feature
@review AggregatorService
@arch data flow from API to caching
@security check input validation in controllers
@perf analyze caching efficiency
@async review StructuredTaskScope in AggregatorService
Agent definitions are located in .claude/agents/.
Remember that you can also trigger agents by natural language according to Claude Code guidelines.
The project includes Claude Code skills that can be invoked as slash commands. Skills are lightweight, focused tasks that run directly in the conversation.
How to use:
- Type the run + slash command in Claude Code (e.g.,
/check-spots) - For skills with arguments, add them after the command (e.g.,
/explain caching flow) - Skills run immediately and return a structured report
Examples:
/check-spots
/explain caching flow
| Command | Purpose |
|---|---|
/check-spots |
Validate spots.json for missing fields, invalid URLs, duplicates, and data consistency |
/check-live-stations |
Analyze live weather station integrations, test data sources, identify spots without live data |
/explain [topic] |
Explain data flows, features, and code paths with visual diagrams and step-by-step breakdowns |
/review [target] |
Quick code review for files or git changes, checking for bugs and best practices |
/audit-security |
Security audit for secrets, SSRF, injection points, dependencies, and headers |
/check-deps |
Analyze Gradle dependencies for outdated versions, CVEs, conflicts, and bloat |
/profile-blocking |
Find blocking calls in reactive WebFlux code that cause thread starvation |
/check-concurrency |
Find race conditions, deadlocks, unsafe shared state, and synchronization issues |
/arch-check |
Verify architecture health: layer violations, circular deps, design patterns |
/check-errors |
Find error handling gaps: swallowed exceptions, missing handlers, resource leaks |
/varun [question] |
Answer questions about kite spots, forecasts, and live wind conditions via varun.surf's public llms.txt endpoints |
/update-docs [topic] |
Update README, CLAUDE.md, AGENTS.md and docs/ to match the current code, and sweep for stale counts, trees and endpoints |
/commit [hint] |
Stage and commit current changes with a message following project conventions |
Skill definitions are located in .claude/skills/.
- showing all kite spots with forecasts and live conditions on the single page without switching between tabs or windows
- browsing forecasts for multiple kite spots
- browsing all kite spots on the map (Open Street Maps)
- watching live wind conditions in the selected spots
- refreshing live wind every one minute on the backend (requires page refresh on the frontend)
- refreshing forecasts every 3 hours in the backend (requires page refresh on the frontend)
- browsing details regarding different spots like description, windguru, windfinder and ICM forecast links, location and webcam
- filtering spots by country
- searching spots
- searching for another spot straight from the single spot view (desktop), which jumps to the spot picked
- possibility to add spots to favorites
- organizing spots in the custom order with a drag and drop mechanism
- dark/light theme
- possibility to switch between a list view and a grid view
- mobile-friendly UI
- kite and board size calculator
- AI forecast analysis, generated on demand from a button under the spot map, written in Polish and English at once so the language switch keeps working, and valid for 24 hours
- ICM forecast generated on demand from a button under the AI analysis, valid for 24 hours
- on a phone, where the spot page renders no map, both generate buttons sit under the forecast model dropdown in the menu instead
- single spot view with hourly forecast (in horizontal and vertical view)
- additional TV-friendly view for the single spot
- map of the spot (Open Street Maps, zoomed in on the spot)
- wind field overlay on the maps (heatmap and animated wind particles) with an hourly forecast timeline stepping through the whole forecast run on a desktop, five days on a phone
- spot visibility toggle on the main map, hiding the markers and clusters so the wind field can be read on its own
- map popups reading the wind, gusts and direction of the hour the forecast timeline stands on, following it as it steps
- sidebar navigation shared by every page, with a mobile drawer
- link to the navigation app (Google Maps)
- displaying a photo of the spot (if available)
- dynamic weather forecast model selector (40+ Windguru models, auto-discovered per spot)
- embeddable HTML widget with current conditions, forecast or a map with the wind field and a 5-day forecast slider for the spot
- session cookie authentication for API access (prevents direct API scraping without visiting the site)
- hero section with random spot photo, name/location, and slogan in PL and EN, hidden from the photo itself and brought back with the Banner button in the sidebar
- automatic language detection from browser settings
- stale live conditions indicators (yellow for outdated data)
- fallback weather station mechanism (automatic switch when primary returns stale data)
- LLM-friendly Markdown endpoints at
/llms/*.md(public, no session cookie) for AI crawlers and agents - Built-in MCP (Model Context Protocol) server at
/mcp/sseexposing spots, forecasts, and live conditions as tools for Claude Code and other AI assistants
The app exposes a Model Context Protocol server so AI assistants can query kite spots, forecasts, and live wind conditions as tools.
The server is built into the Spring Boot app (Spring AI 1.0.x spring-ai-starter-mcp-server-webflux) and runs over Server-Sent Events at:
| Path | Description |
|---|---|
GET /mcp/sse |
SSE stream β clients connect here to receive server events |
POST /mcp/message |
Message endpoint β clients send JSON-RPC requests here |
Both paths are public (no session cookie required), same as /llms/*.md.
| Tool | Description |
|---|---|
list_spots |
Markdown index of all kite spots, grouped by country |
get_spot |
Full spot details (overview, current conditions, daily/hourly forecast, links) by Windguru spot ID (wgId) |
get_wind_forecast |
Hour-by-hour wind, gusts and direction for one spot (wgId), optionally limited to a number of hours and a minimum wind speed |
find_spot_by_name |
Search spots by case-insensitive substring match |
find_windy_spots |
Scan every spot's hourly wind at once for the ones reaching a given wind speed, optionally within one country |
list_countries |
List all countries with spot counts |
get_spots_by_country |
List spots in a country by slug (e.g. poland, czech-republic) |
get_status |
Quick summary of spots / countries / live stations counts |
Add the production server:
claude mcp add --transport sse varun-surf https://varun.surf/mcp/sse
Or for a local development server:
claude mcp add --transport sse varun-surf-local http://localhost:8080/mcp/sse
Verify it's connected:
claude mcp list
You can now ask Claude things like "what's the wind forecast for Jastarnia tomorrow?" or "list all kite spots in Spain", and it will call the relevant tool on the varun.surf MCP server.
The /mcp page displays the live MCP endpoint URL, a ready-to-use JSON config and a one-click copy of the install command, along with the list of available tools.
MCP-related configuration in application.yml:
spring:
ai:
mcp:
server:
enabled: true
name: varun-surf
version: ${version}
type: ASYNC # WebFlux uses async transport
sse-endpoint: /mcp/sse
sse-message-endpoint: /mcp/messageTo disable the MCP server entirely set spring.ai.mcp.server.enabled=false.
The app exposes a set of public Markdown documents under /llms/*.md for LLMs, AI crawlers and agents.
These endpoints are not gated by the session cookie (unlike /api/v1/**) and are linked from /llms.txt.
| Endpoint | Description |
|---|---|
GET /llms/spots.md |
Index of all kite spots with links to per-spot documents and a list of countries |
GET /llms/spots/{wgId}.md |
Full spot document: overview, current conditions (when available), daily/hourly forecast, links |
GET /llms/spots/{wgId}/wind.md |
One spot's wind hour by hour: wind, gusts and direction, with the windiest hour called out |
GET /llms/wind.md |
Every spot reaching a given wind speed in the hours ahead, strongest first |
GET /llms/countries.md |
Index of all countries with spot counts |
GET /llms/countries/{slug}.md |
Spots available in the given country |
The country {slug} is the lowercased country name with spaces replaced by hyphens (e.g. poland, czech-republic).
All responses are served as text/markdown; charset=UTF-8 and use the same in-memory caches as the JSON API.
The two wind documents read the same grid-aligned hourly forecast /api/v1/forecast/{wgId} and
/api/v1/wind serve, and take optional query parameters:
| Endpoint | Parameters |
|---|---|
/llms/spots/{wgId}/wind.md |
hours (ahead, default 72), minWind (knots, default: list every hour) |
/llms/wind.md |
minWind (knots, default 12), hours (ahead, default 24), country (name or slug), limit (default 20, max 100) |
An unknown spot or country answers 404; a span longer than the forecast reaches is trimmed to what there is.
Forecasts come from Windguru, Windfinder and ICM Meteo. Live wind comes from the weather stations listed on the sources page, which also lists the sources behind the spot database itself:
| Source | Contribution |
|---|---|
| Kitewetter | Alpine kite spots in Austria |
| Nederlandse Kitesurf Vereniging | Dutch kite spots, their access rules and local restrictions |
| Dziobak | Polish inland and Puck Bay spots, plus wind/wing/kite destinations abroad |
The NKV spotkaart is maintained by a network of volunteer spot managers who keep Dutch kite spots open and safe β the local knowledge in the Netherlands entries comes from their work. Spots the NKV lists as verboden (forbidden) are deliberately not included here.

