What You'll End Up With
- A Python webhook handler running as a systemd service
- Automatic restart on crash with exponential backoff
- Health check endpoint for monitoring
- Structured JSON logging with rotation
- Caddy reverse proxy with HTTPS
- Environment-variable-based configuration (no secrets in code)
- Thread-safe request handling for concurrent requests
Why Not Flask or FastAPI?
This guide uses Python's built-in http.server module. No pip installs needed. No dependency management. No virtualenv updates breaking production at 3 AM.
When to upgrade: If you need async, WebSockets, or automatic OpenAPI docs, switch to FastAPI. But for a webhook handler that receives POST requests and returns 200, the standard library is all you need. Fewer dependencies = fewer security holes.
Prerequisites
- A VPS running Ubuntu 22.04+ (tested on Hetzner CX22, $5/mo)
- Python 3.10+ (pre-installed on Ubuntu 22.04+)
- Caddy installed and configured (see Caddy HTTPS guide)
- A domain pointing to your VPS
Step 1: Create the Project
sudo mkdir -p /opt/webhook
sudo chown $USER:$USER /opt/webhook
cd /opt/webhook
Step 2: Write the Handler
#!/usr/bin/env python3
"""Production webhook handler — stdlib only, no dependencies."""
import json
import logging
import os
import signal
import sys
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
# Configuration from environment
PORT = int(os.environ.get("WEBHOOK_PORT", "8888"))
LOG_DIR = os.environ.get("WEBHOOK_LOG_DIR", "/var/log/webhook")
HEALTH_SECRET = os.environ.get("WEBHOOK_HEALTH_SECRET", "")
# Structured logging
logging.basicConfig(
level=logging.INFO,
format='{"time":"%(asctime)s","level":"%(levelname)s",'
'"module":"%(module)s","message":"%(message)s"}',
handlers=[
logging.FileHandler(f"{LOG_DIR}/handler.log"),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Thread-safe request counter
request_count = threading.Counter() if hasattr(threading, 'Counter') else None
_request_lock = threading.Lock()
_request_total = 0
def increment_requests():
global _request_total
with _request_lock:
_request_total += 1
class WebhookHandler(BaseHTTPRequestHandler):
"""Handle incoming webhook requests."""
def do_GET(self):
"""Health check endpoint."""
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
response = {
"status": "healthy",
"uptime": time.time() - START_TIME,
"requests": _request_total
}
self.wfile.write(json.dumps(response).encode())
return
self.send_response(404)
self.end_headers()
def do_POST(self):
"""Process incoming webhook payloads."""
increment_requests()
if self.path != "/webhook":
self.send_response(404)
self.end_headers()
return
try:
content_length = int(self.headers.get("Content-Length", 0))
if content_length > 1_000_000: # 1MB limit
logger.warning(f"Oversized payload: {content_length} bytes")
self.send_response(413)
self.end_headers()
return
body = self.rfile.read(content_length)
data = json.loads(body)
# Extract what you need
event_type = data.get("type", "unknown")
logger.info(f"Received event: {event_type}")
# YOUR LOGIC HERE
# This is where you process the webhook payload
result = process_event(data)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok", "result": result}).encode())
except json.JSONDecodeError:
logger.warning("Invalid JSON payload")
self.send_response(400)
self.end_headers()
self.wfile.write(b'{"error":"invalid json"}')
except Exception as e:
logger.error(f"Processing error: {e}", exc_info=True)
self.send_response(500)
self.end_headers()
self.wfile.write(b'{"error":"internal error"}')
def log_message(self, format, *args):
"""Override default logging to use structured logger."""
logger.info(f"{self.client_address[0]} - {format % args}")
def process_event(data):
"""Your webhook logic goes here."""
event_type = data.get("type", "unknown")
# Example: just echo back what we received
return {"processed": True, "event": event_type}
# Graceful shutdown
START_TIME = time.time()
def signal_handler(signum, frame):
logger.info(f"Received signal {signum}, shutting down...")
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", PORT), WebhookHandler)
logger.info(f"Webhook handler starting on port {PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
logger.info("Server stopped")
This handler runs on Python stdlib only. No pip installs. No virtualenv. No version conflicts. Deploy it and it works.
Step 3: Set Up Logging Directory
sudo mkdir -p /var/log/webhook
sudo chown $USER:$USER /var/log/webhook
Step 4: Environment Variables
sudo tee /etc/webhook.env <<EOF
WEBHOOK_PORT=8888
WEBHOOK_LOG_DIR=/var/log/webhook
WEBHOOK_HEALTH_SECRET=change-me-to-a-random-string
EOF
sudo chmod 600 /etc/webhook.env
sudo chown root:root /etc/webhook.env
Never commit this file. Add
/etc/webhook.env to your deployment checklist. Each environment should have its own secrets file.Step 5: systemd Service
sudo tee /etc/systemd/system/webhook.service <<EOF
[Unit]
Description=Python Webhook Handler
After=network-online.target caddy.service
Wants=network-online.target
[Service]
Type=simple
User=webhook
Group=webhook
WorkingDirectory=/opt/webhook
EnvironmentFile=/etc/webhook.env
ExecStart=/usr/bin/python3 /opt/webhook/handler.py
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/webhook /var/log/webhook
PrivateTmp=true
# Resource limits (3.5GB cap on 4GB VPS)
MemoryHigh=3G
MemoryMax=3500M
StandardOutput=journal
StandardError=journal
SyslogIdentifier=webhook
[Install]
WantedBy=multi-user.target
EOF
# Create the user
sudo useradd -r -s /bin/false webhook
sudo chown -R webhook:webhook /opt/webhook
sudo chown -R webhook:webhook /var/log/webhook
sudo systemctl daemon-reload
sudo systemctl enable webhook
sudo systemctl start webhook
Verify:
sudo systemctl status webhook should show "active (running)".Step 6: Caddy Reverse Proxy
Add the webhook to your Caddyfile:
# Add to /etc/caddy/Caddyfile
webhook.yourdomain.com {
reverse_proxy localhost:8888
encode gzip
# Rate limiting (100 requests per minute per IP)
rate_limit {
zone webhook_zone {
key {remote_host}
events 100
window 1m
}
}
}
sudo systemctl reload caddy
Verify:
curl https://webhook.yourdomain.com/health should return {"status":"healthy",...}Testing Your Webhook
# Health check
curl -s https://webhook.yourdomain.com/health | python3 -m json.tool
# Send a test payload
curl -X POST https://webhook.yourdomain.com/webhook \
-H "Content-Type: application/json" \
-d '{"type":"test","message":"hello"}'
# Check logs
sudo journalctl -u webhook -n 20 --no-pager
# Check application logs
tail -20 /var/log/webhook/handler.log
Log Rotation
sudo tee /etc/logrotate.d/webhook <<EOF
/var/log/webhook/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 webhook webhook
}
EOF
Monitoring (5-Minute Health Check)
sudo tee /opt/webhook/healthcheck.sh <<'EOF'
#!/bin/bash
# Health check script — run via cron every 5 minutes
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/health)
if [ "$RESPONSE" != "200" ]; then
echo "[$(date)] Webhook unhealthy (HTTP $RESPONSE), restarting..." >> /var/log/webhook/healthcheck.log
sudo systemctl restart webhook
fi
EOF
sudo chmod +x /opt/webhook/healthcheck.sh
# Add to crontab
(crontab -l 2>/dev/null; echo "*/5 * * * * /opt/webhook/healthcheck.sh") | crontab -
Why not just rely on systemd Restart=always? Systemd restarts the process when it crashes. But a "zombie" process that's running but not responding won't be caught by systemd. The health check catches that case.
Troubleshooting
Service won't start
# Check systemd journal for errors
sudo journalctl -u webhook -n 50 --no-pager
# Common issues:
# - Permission denied: check chown on /opt/webhook and /var/log/webhook
# - Port already in use: sudo ss -tlnp | grep 8888
# - Python error: check handler.py for syntax errors
502 Bad Gateway from Caddy
Caddy is running but can't reach the backend. Check:
- Is the webhook service running?
systemctl is-active webhook - Is it listening on the right port?
ss -tlnp | grep 8888 - Is Caddy proxying to the right port? Check
Caddyfile
Memory usage growing
# Check memory usage
ps aux | grep handler.py
# If growing, add these to the systemd unit:
# MemoryHigh=3G
# MemoryMax=3500M
On a 4GB VPS, capping at 3.5GB leaves 500MB for the OS, Caddy, and systemd.
Webhook not receiving requests
# Test locally first
curl -s http://localhost:8888/health
# Then test through Caddy
curl -s https://webhook.yourdomain.com/health
# If local works but Caddy doesn't, check:
# 1. DNS: dig webhook.yourdomain.com
# 2. Caddy config: caddy validate --config /etc/caddy/Caddyfile
# 3. Caddy logs: sudo journalctl -u caddy -n 20
Verification Checklist
| Check | Command | Expected |
|---|---|---|
| Service running | systemctl is-active webhook | active |
| Health check | curl localhost:8888/health | {"status":"healthy"} |
| HTTPS valid | Open in browser | Valid cert |
| POST endpoint | curl -X POST .../webhook -d '{...}' | {"status":"ok"} |
| Auto-restart | sudo kill $(pgrep -f handler.py) | Service back up in <10s |
| Log rotation | sudo logrotate -d /etc/logrotate.d/webhook | No errors |
| Memory capped | systemctl show webhook | grep MemoryMax | 3500M |
Cost Breakdown
| Item | Cost | Notes |
|---|---|---|
| Hetzner CX22 VPS | $5/mo | 4GB RAM, 40GB disk — runs webhook + Caddy with headroom |
| Domain | $10/yr | Any registrar |
| Caddy | Free | Auto-HTTPS |
| Python | Free | Pre-installed on Ubuntu |
| Total | ~$6/mo | VPS + domain |