██████╗ ██╗ ██╗ ██╗ ███████╗ ███████╗ ██╗ ██╗ ███╗ ███╗ █████╗ █████╗
██╔══██╗ ██║ ██║ ██╔╝ ██╔════╝ ██╔════╝ ██║ ██║ ████╗ ████║ ██╔══██╗ ██╔══██╗
██████╔╝ ██║ █████╔╝ ███████╗ █████╗ ██║ ██║ ██╔████╔██║ ███████║ ███████║
██╔═══╝ ██║ ██╔═██╗ ╚════██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║ ██╔══██║ ██╔══██║
██║ ██║ ██║ ██╗ ███████║ ███████╗ ███████╗ ██║ ██║ ╚═╝ ██║ ██║ ██║ ██║ ██║
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
Technical work I've shipped — architecture, trade-offs, and honest reflections. Click any entry to expand the full case study.
Running a self-hosted AI chat service (Open WebUI + Ollama) for personal use requires GDPR compliance — users must give informed, documented consent before their data is processed. No lightweight, off-the-shelf solution exists for adding a consent layer to arbitrary self-hosted services. The service also needed a verifiable audit trail to demonstrate compliance without depending on any third-party cloud provider.
Built a TypeScript reverse proxy that sits in front of the upstream service and intercepts every request. Before forwarding traffic, it validates a signed consent cookie against a local database. Users who have not consented are served a privacy policy page; those who accept receive a signed cookie that grants access for one year. All consent events are written to an append-only audit log. The proxy is service-agnostic and designed to front multiple services, each with their own independently versioned policy.
sequenceDiagram
actor User as User (Browser)
participant NX as Nginx (TLS)
participant CP as Consent Proxy
participant DB as SQLite
participant OW as Open WebUI
User->>NX: HTTPS request
NX->>CP: HTTP (internal)
CP->>DB: Validate consent cookie
alt No valid consent
DB-->>CP: Invalid / not found
CP-->>User: 200 Privacy Policy page
User->>CP: POST /consent (accept)
CP->>DB: Write consent record + audit log
CP-->>User: Set signed cookie, redirect
else Valid consent
DB-->>CP: Valid
CP->>OW: Forward request
OW-->>CP: Response (HTTP or WebSocket)
CP-->>User: Forward response
end
WebSocket proxying through a Fastify HTTP pipeline: Open WebUI uses Socket.IO for real-time streaming. Routing WebSocket upgrades through Fastify's request pipeline left the Node.js HTTP parser attached to the client socket as a competing data listener, silently consuming all client-to-upstream bytes. Diagnosed via systematic debug logging and fixed by handling the upgrade event directly on the raw Node.js HTTP server, bypassing the framework entirely.
Bidirectional socket piping without data loss: After the WebSocket handshake, both sockets must exchange raw bytes in both directions simultaneously. The solution uses Node.js stream.pipe() on clean sockets obtained from the native server.on('upgrade') event, ensuring no buffered bytes are lost and no competing listeners interfere.
Empty-body JSON requests breaking the proxy: Open WebUI sends DELETE requests with Content-Type: application/json but no body — a valid HTTP pattern that Fastify's default parser rejects with a 400 error. Fixed with a custom content-type parser that accepts and passes through empty bodies, making the proxy transparent to all request shapes.
| Category | Technologies |
|---|---|
| Language | TypeScript (Node.js 20) |
| Framework | Fastify |
| Database | SQLite (via better-sqlite3) |
| Infrastructure | Docker, Alpine Linux, Nginx |
| Frontend | EJS templates, HTMX |
What I'd do differently: Start from an established API gateway (such as Kong) and extend it with a consent plugin, rather than implementing raw HTTP and WebSocket proxying from scratch — the lower-level socket handling surfaces edge cases that mature gateways have already solved.
What worked well: The decision to store all routing and policy configuration in a database rather than code made the proxy genuinely service-agnostic from day one, and kept the blast radius of any single configuration change small.
Finding and inserting emoji should be simple, but most solutions compromise on privacy or usability:
The goal was a fast, searchable, install-nothing emoji tool that respects user privacy — no cookies, no analytics, no external scripts.
A self-hosted, single-page emoji picker with instant search and a virtual keyboard. Zero build steps, zero runtime dependencies, zero tracking.
flowchart LR
Browser[Browser] -->|Static Files| Nginx[nginx]
Nginx -->|HTML/CSS/JS| App[Emoji Picker App]
App -->|Emoji Data| Local[(Local JSON)]
App -->|Copy to clipboard| Clipboard[Clipboard API]
App -->|Virtual keyboard input| Output[Target Input]
Data Flow:
Built with vanilla Web Components instead of frameworks:
class EmojiPicker extends HTMLElement {
connectedCallback() {
this.searchInput = this.querySelector('#search');
this.grid = this.querySelector('#grid');
this.searchInput.addEventListener('input', (e) => this.filter(e.target.value));
}
filter(query) {
const matches = this.emojiData.filter(e =>
e.shortcode.includes(query) ||
e.keywords.some(k => k.includes(query))
);
this.renderGrid(matches);
}
}
Why not React/Vue? The tool needed to be framework-agnostic and embeddable anywhere. A custom element can be dropped into any HTML page regardless of what tech stack the parent site uses.
Modern navigator.clipboard.writeText() for HTTPS contexts, with graceful degradation to document.execCommand('copy') for older browsers or non-secure contexts (like localhost development).
The complete emoji set (3,600+ emoji) is loaded as a single JSON file. This trades initial load time (500KB) for instant search response. For slower connections, the JSON loads asynchronously while a skeleton UI displays immediately.
| Category | Technologies |
|---|---|
| Language | ES2020+ (vanilla) |
| Components | Web Components API |
| Styling | CSS Custom Properties |
| Server | nginx |
| Data | emoji-data.json |
| Icons | System emoji fonts |
What I'd do differently: Consider lazy-loading emoji categories instead of the full dataset upfront. The 500KB payload is fine on broadband but could be chunked for slower mobile connections.
What worked well: Keeping it vanilla. No build step means the tool will still work in 10 years when today's frameworks are deprecated. Web Components are boring technology — exactly what's needed for a utility tool.
Every file you upload to Dropbox, Google Drive, or any big-tech file service becomes their file. It lives on their servers, in jurisdictions you don't control, subject to their terms, their scanning algorithms, and their data retention policies. This isn't hypothetical — it's a compliance liability under GDPR, and a practical one for anyone who values privacy.
Commercial solutions also have ceilings. Upload limits that throttle large files. Interruptions that force you to restart multi-gigabyte transfers from zero. Recurring costs that scale with data you already own.
The question was simple: What does it take to build something better for private use?
The answer is Filebrowser — an open-source, web-based file manager — customized with TUS resumable upload support and wrapped in a Docker container. This isn't a from-scratch rewrite. It's a surgical enhancement to an existing solid foundation.
Filebrowser provides the UI, authentication, and file operations. The TUS protocol integration adds the killer feature: resumable uploads. If your connection drops during a 10GB transfer, you don't start over. You resume exactly where you left off.
The entire stack runs containerized behind nginx with HTTP/2 enabled, delivering modern performance without cloud dependency.
TUS is an open protocol for resumable file uploads over HTTP. Instead of uploading a file in one monolithic POST, files are transferred in chunks with PATCH requests. Each chunk is acknowledged, and the server tracks upload state server-side. If the connection drops, the client queries the server for the last received offset and resumes from there.
The integration required:
HTTP/2 isn't just "faster HTTP." For file uploads, it brings:
The nginx configuration enables HTTP/2 and tunes buffer sizes for large file handling. The Docker container exposes the service on a static IP within a private bridge network, reverse-proxied by the host's nginx.
This is the point. Data never leaves your hardware. Files are stored locally, served locally, and accessed locally. There's no "sync to the cloud," no third-party service with a privacy policy written by lawyers who hope you won't read it.
From a compliance perspective, this is as simple as it gets:
The system is private by design. Authentication is required. The service is not exposed to the public internet without proper access controls.
Self-hosting isn't paranoia. It's competence.
Finland averages 20–40 thunderstorm days per year, with peak activity concentrated in July and August. While the Finnish Meteorological Institute (Ilmatieteenlaitos) provides open lightning data through a WFS API, accessing it in real-time requires technical expertise and infrastructure. Existing commercial weather services bury this data behind cluttered interfaces and tracking-heavy advertising. There was no clean, privacy-respecting lightning radar specifically designed for Finland that could be viewed on both desktop and mobile without user tracking or third-party dependencies.
The project needed to solve three specific constraints:
A purpose-built lightning radar with a zero-dependency Python backend serving a vanilla HTML/CSS/JS frontend. The architecture deliberately avoids build tools, package managers, and heavyweight frameworks while delivering sub-second live updates via Server-Sent Events.
flowchart LR
FMI[FMI WFS API
Open Data] -->|Poll every 6s| Poller[Python 3
stdlib only]
Poller -->|Store + dedupe| Cache[(SQLite
+ In-memory)]
Cache -->|SSE stream| Client[Browser
Vanilla JS]
Client -->|Leaflet.js CDN| Map[Interactive Map]
Client -->|Service Worker| PWA[Offline-capable PWA]
Data Flow:
/api/stream via Server-Sent Events for a persistent HTTP connectionrequirements.txt, no dependency updates, no CVE notifications--fake flag generates synthetic strikes for development and load testing without touching FMI APIsThe original implementation used browser polling—every client making repeated HTTP requests. This generated unnecessary load and introduced latency. The SSE implementation:
X-Accel-Buffering: no header to hint reverse proxies to disable bufferingBrowser clients use EventSource where available, falling back to the original polling mechanism for legacy support. The delta encoding reduces data transfer significantly: a thunderstorm with 3,000 active strikes sends full GeoJSON on connect, then ~100-byte deltas for each new or expired strike rather than re-downloading the entire dataset.
The Python backend deliberately uses only the standard library:
http.server + socketserver for the HTTP layer (not production-grade for high load, but sufficient for this use case)sqlite3 for persistent strike history with WAL modeurllib.request for FMI API callsxml.etree.ElementTree for parsing FMI's MultiPointCoverage XMLthreading and threading.Condition for concurrent SSE connectionshashlib for stable strike ID generationdataclasses and pathlib for clean code structureWhy this matters: No pip install, no virtualenv, no dependency lock files, no Renovate PRs, no supply chain attacks via compromised packages. The application can be run on any system with Python 3.10+ installed, including air-gapped environments.
Municipality and region assignment is done entirely locally using Statistics Finland's boundary polygons:
The boundary data is committed as a processed JSON file; the original 1:1,000,000 polygons are processed by a script that also handles municipality-to-region mappings.
FMI's WFS service occasionally delays strike reporting by minutes or tens of minutes. The poller implements a two-tier strategy:
Results are deduplicated by stable SHA1-derived IDs, so overlapping windows don't create duplicates. Reconciled observations appear on the map without new-strike flash animations (they're too old for that visual treatment).
The service worker caches the application shell and pinned Leaflet assets:
const APP_SHELL = [
"/",
"/styles.css",
"/app.js",
"/manifest.webmanifest",
"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
];
API calls and map tiles are never cached—offline mode shows the cached app shell but gracefully degrades without map data. When a new service worker is installed, an "Update available" banner appears; activating it reloads the page once and preserves user preferences.
The production deployment uses Docker with a minimal Python 3 Alpine base:
| Variable | Default | Purpose |
|---|---|---|
FMI_POLL_INTERVAL_SECONDS |
6 | Seconds between FMI requests |
FMI_LOOKBACK_MINUTES |
2 | Overlapping window for normal polls |
STRIKE_RETENTION_MINUTES |
120 | How long strikes stay in live cache |
HISTORY_DATABASE_PATH |
data/strikes.sqlite3 |
SQLite archive path |
HISTORY_RETENTION_DAYS |
0 | Archive retention (0 = keep all) |
At the default 6-second interval, the backend makes at most 14,400 requests per day—well within FMI's 20,000/day limit for the Download Service.
No user tracking. GDPR-safe by design.
The application only stores lightning strike observations (public FMI data) in its SQLite database. No personal data is collected, processed, or retained.
Important: This application is informational only and must not be used as a safety or warning system.
Live site: lightnings.pikselimaa.fi
Source: Private repository (family project)
Organisations face a strategic dilemma when deploying AI: local inference offers privacy and compliance but lacks the raw power of frontier models for complex multi-step workflows. Cloud APIs provide capability but sacrifice data sovereignty and create vendor lock-in.
The challenge is orchestration — routing workloads intelligently between local and cloud resources without fragmenting the developer experience or compromising sensitive data.
A hybrid inference architecture that treats computational resources as a unified layer, automatically selecting the optimal backend based on workload characteristics and data sensitivity.
Architecture:
flowchart TB
subgraph Client["Client Layer"]
UI[Open WebUI]
API[API Consumers]
end
subgraph Inference["Inference Router"]
Router[Workload Router]
end
subgraph Local["Local Infrastructure"]
vLLM[vLLM Engine]
Ollama[Ollama Cache]
GPU[NVIDIA GPU Cluster]
end
subgraph Cloud["Cloud Providers"]
Bedrock[AWS Bedrock]
Frontier[Frontier Models]
end
Client --> Router
Router -->|Privacy-critical| Local
Router -->|High-compute| Cloud
vLLM --> GPU
Ollama --> GPU
Component Roles:
The inference layer runs on dedicated GPU infrastructure, while the web interface itself operates on modest hardware — demonstrating a clean separation between presentation and compute.
vLLM as Datacenter-Grade Engine
vLLM replaces traditional inference servers with continuous batching and PagedAttention, delivering significantly higher throughput on the same hardware. This isn't hobbyist tooling — it's the same engine powering production AI services at scale.
Multi-Provider Abstraction
The architecture presents a unified API regardless of whether the underlying provider is local vLLM or AWS Bedrock. Application code remains provider-agnostic; routing decisions are infrastructure concerns.
Resource Separation
The Open WebUI frontend runs independently from the inference layer. This separation allows the interface to operate on lightweight hardware (the public-facing web server) while compute-heavy inference happens on optimised GPU infrastructure.
Extended AI Ecosystem
Beyond text inference, the infrastructure integrates with:
Data Handling:
GDPR Compliance:
Hybrid Security Model: The architecture acknowledges that not all workloads require the same security posture. Routine automation may use cloud inference; customer data analysis stays strictly local.
This infrastructure demonstrates that hybrid AI is not a compromise but an optimisation. Organisations can retain control over sensitive data while accessing frontier capabilities when needed.
Commercial CCTV solutions are convenient — until you read their privacy policies. Most consumer-grade systems:
For a home security system, these tradeoffs were unacceptable. The goal was simple: recorded video should never leave the local network. Full stop.
Built a self-hosted CCTV system with a React SPA frontend and Node.js backend, fully containerized with Docker. The system operates entirely on local infrastructure — no cloud dependencies, no vendor lock-in, no data exfiltration.
Architecture:
flowchart LR
Browser[React SPA]
API[Node.js API]
SSE[SSE Stream]
Storage[(Video Storage)]
Camera[Camera Sources]
Browser <-->|HTTP /api| API
API <-->|Byte-range| Storage
API -->|Motion Events| SSE
SSE -->|Real-time| Browser
Camera -->|RTSP| API
The backend runs on dedicated GPU hardware for motion detection inference, keeping processing local. All communication uses standard HTTP with SSE for real-time events.
Real-time motion alerts via SSE: The moment motion is detected, a Server-Sent Events (SSE) stream pushes a notification to all connected browsers instantly — no polling, no delays.
Seekable video playback: HTTP byte-range streaming allows jumping to any timestamp in a clip without buffering the entire file first. Large recordings behave like YouTube: scrub to any point, play immediately.
Zero cloud dependency: All components run on-site. No accounts. No subscriptions. No data egress.
Private access only: The system is not publicly exposed. Access requires local network presence or VPN.
React + SSE: State management with streaming events
The challenge with SSE in React is managing a long-lived connection across component lifecycles. The solution: a custom hook that maintains the EventSource connection, handles reconnection with exponential backoff, and exposes a reactive message queue via a simple state machine.
// Simplified: SSE connection manager pattern
const useEventStream = (url) => {
const [messages, setMessages] = useState([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
let retryDelay = 1000;
let maxDelay = 30000;
const connect = () => {
const source = new EventSource(url);
source.onopen = () => { setConnected(true); retryDelay = 1000; };
source.onmessage = (e) => setMessages(prev => [...prev, JSON.parse(e.data)]);
source.onerror = () => {
setConnected(false);
source.close();
setTimeout(connect, Math.min(retryDelay, maxDelay));
retryDelay *= 2;
};
};
connect();
return () => source?.close();
}, [url]);
return { messages, connected };
};
Video streaming: Byte-range request handling
Standard HTML5 <video> elements support HTTP range requests (RFC 7233), but the backend must honor them. The Node.js backend streams file chunks on-demand, returning 206 Partial Content with correct Content-Range headers. This lets the browser seek arbitrarily through multi-gigabyte recordings without loading the entire file into memory.
Key nginx configuration for proper range handling:
location /api/recordings/ {
proxy_pass http://backend;
proxy_http_version 1.1;
# Required for byte-range streaming
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
}
Docker containerization
The entire stack — frontend build artifacts, API server, and nginx reverse proxy — runs in a single Docker container. This simplifies deployment and ensures environment consistency. The backend has read-only access to the video storage volume; no writable filesystem paths for the frontend static files.
GDPR compliant by design
Network isolation
The system operates on a private local network. There is no public DNS record exposing the service directly, and the backend refuses connections from non-local networks. This eliminates the attack surface of "cloud-connected" IoT devices that have become a favorite target for botnets.
Physical security
Recordings are stored on local encrypted storage. The Docker volume persists only to trusted hardware under physical control.
SSE is underrated for private dashboards
WebSockets are overkill for one-way server→client notifications. SSE is simpler (standard HTTP), auto-reconnects natively, and works through most corporate proxies. For a private dashboard where the browser is always a consumer, SSE wins.
Byte-range streaming is "free" with nginx
The hard part is ensuring your backend supports range requests. Once that's solved, the browser's native <video> player handles seeking, buffering, and adaptive quality without custom JavaScript.
Self-hosting isn't harder — it's different
Commercial solutions optimize for onboarding speed. Self-hosted solutions optimize for longevity and control. The upfront investment in Docker, nginx, and SSE pays off in zero ongoing subscription costs and complete data sovereignty.
Status: Production | Access: Private (local network only)
The cloud AI assistant trap: Commercial AI assistants (Alexa, Siri, ChatGPT) require sending your data to corporate servers. For a CTO handling sensitive information — code, architecture decisions, business strategy — this creates unacceptable privacy and compliance risks. Additionally, these assistants lack personality continuity and can't be customized to specific workflows.
The self-hosting challenge: Running local LLMs is technically complex. You need:
The specific need: An executive assistant that could:
Sofia Botvik — an AI executive assistant running on the OpenClaw framework inside an isolated KVM virtual machine.
flowchart TB
subgraph User["User Interaction"]
Telegram[Telegram Bot]
Voice[Voice Input]
Calendar[Calendar Access]
Todo[Task List]
end
subgraph VM["Isolated Environment"]
OpenClaw[OpenClaw Framework]
Persona[Sofia Botvik Persona]
Tools[Tool Executor]
end
subgraph Services["Local Services"]
Whisper[Whisper STT]
Piper[Piper TTS]
Email[Email Gateway]
LLM[Ollama / vLLM]
end
User --> OpenClaw
Voice --> Whisper --> OpenClaw
OpenClaw --> Tools
Tools --> Email
Tools --> Calendar
Tools --> Todo
OpenClaw --> LLM
OpenClaw -.->|Responses| Telegram
1. Isolated Execution Environment
2. OpenClaw Framework
openclaw.json (JSON5)3. Sofia Botvik Persona (SOUL.md)
4. Executive Assistant Capabilities
Calendar Management:
Task Management:
Intelligent Pesting:
Communication Handling:
6. Tool Delegation Sofia understands when to handle tasks directly versus delegating to specialized systems:
Open WebUI Integration:
This delegation pattern allows Sofia to remain lightweight while accessing frontier capabilities when needed.
5. Voice I/O
6. Tool Delegation Sofia understands when to handle tasks directly versus delegating to specialized systems:
Open WebUI Integration:
This delegation pattern allows Sofia to remain lightweight while accessing frontier capabilities when needed.
7. LLM Inference Stack
Sofia follows a "trust earned" philosophy:
This mirrors human internship progression — prove competence before receiving autonomy.
Automated cron job at 08:00 Europe/Helsinki:
1. Search configured sources for overnight tech/AI developments
2. Prioritize by relevance to Pikselimaa interests
3. Draft summary with executive overview, detailed sections, action items
4. Deliver via Telegram with professional signature
Content focus:
Natural code-switching based on context:
Multiple levels of emergency control:
openclaw-kill-switch script (4 modes: full, network, process, emergency)/usr/local/bin/self-terminate for agent-initiated shutdownResponse times: sub-second to 2 seconds depending on mode.
The project originally considered LXD containers but switched to KVM/libvirt for true isolation:
All agent behavior defined in version-controlled files:
openclaw.json — Framework configuration (models, tools, cron)SOUL.md — Personality definition, communication patterns, escalation rulesIDENTITY.md — Detailed background, competencies, mental models| Phase | Status | Notes |
|---|---|---|
| VM Infrastructure | ✅ Complete | KVM VM with hardware isolation |
| GPU Connectivity | ✅ Complete | Ollama inference layer operational |
| OpenClaw Install | ✅ Complete | Framework deployed and configured |
| Persona Config | ✅ Complete | SOUL.md and IDENTITY.md finalized |
| Telegram Bot | ✅ Complete | Bot active with DM support |
| Daily Summaries | ✅ Complete | Automated 8 AM briefing operational |
| Email Integration | ✅ Complete | SMTP gateway active |
| Voice I/O | ✅ Complete | Whisper STT and Piper TTS integrated |
| Browser Automation | ✅ Complete | Playwright enabled for web tasks |
1. Personality Engineering Matters The SOUL.md framework (Style, Objectives, Understanding, Limitations) creates more consistent agent behavior than simple prompting. Sofia's "cautious intern" persona leads to better trust calibration than a generic "helpful assistant."
2. Isolation vs. Performance Trade-off KVM VMs provide security isolation but add latency for GPU inference (network round-trip to GPU infrastructure). For this use case, the security benefits outweigh the latency cost.
3. Graduated Trust is Safer Starting with restricted tool access and expanding based on observed behavior is more secure than starting with full permissions and hoping for the best. The "intern" mental model makes this intuitive.
4. Trilingual Support Requires Context Awareness Code-switching should be context-aware, not random. Sofia uses Swedish for formal business communication (Åbo Akademi background), Finnish for internal team chat, and English for technical discussions.
Operational:
Enhancements:
Sofia Botvik represents a paradigm shift: an AI assistant that prioritizes privacy, demonstrates trustworthiness through behavior, and operates within clearly defined boundaries. In an era of cloud-dependent AI, running locally isn't just a technical choice — it's a statement about data sovereignty.
*** END OF LIST ***