FREE GUIDE Plan 1c · Tested 2026-07-22

Run a Python Webhook
on a $5 VPS

20 minutes · 6 steps · Production handler with systemd, health checks, and auto-restart

Want the full version? Thread-safe state management, webhook signature verification, rate limiting, and deployment automation.

Get the Full Pack →

What You'll End Up With

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

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:

  1. Is the webhook service running? systemctl is-active webhook
  2. Is it listening on the right port? ss -tlnp | grep 8888
  3. 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

CheckCommandExpected
Service runningsystemctl is-active webhookactive
Health checkcurl localhost:8888/health{"status":"healthy"}
HTTPS validOpen in browserValid cert
POST endpointcurl -X POST .../webhook -d '{...}'{"status":"ok"}
Auto-restartsudo kill $(pgrep -f handler.py)Service back up in <10s
Log rotationsudo logrotate -d /etc/logrotate.d/webhookNo errors
Memory cappedsystemctl show webhook | grep MemoryMax3500M

Cost Breakdown

ItemCostNotes
Hetzner CX22 VPS$5/mo4GB RAM, 40GB disk — runs webhook + Caddy with headroom
Domain$10/yrAny registrar
CaddyFreeAuto-HTTPS
PythonFreePre-installed on Ubuntu
Total~$6/moVPS + domain

This is the free version. The full Operator Playbook Pack includes thread-safe state management, webhook signature verification, deployment automation, and production monitoring patterns.

Get the Full Pack →