Caddy web server with the Tailscale plugin, packaged for pip installation. Run any Python web app on your tailnet with one command — Flask, FastAPI, Django, or any WSGI/ASGI callable.
pip install caddytailWrite a normal Flask app — no CaddyTail-specific setup needed:
# app.py
from flask import Flask
from caddytail import get_user
app = Flask(__name__)
@app.get("/")
def index():
user = get_user()
return f"Hello, {user.name}!"Or use any WSGI callable — no framework required:
# app.py
from caddytail import get_user
def app(environ, start_response):
user = get_user(environ)
body = f"Hello, {user.name}!" if user else "Not authenticated"
start_response("200 OK", [("Content-Type", "text/plain")])
return [body.encode()]Run it on your tailnet:
caddytail run myapp app:appThat's it. Your app is now available at https://myapp.<tailnet>.ts.net with Tailscale authentication.
Hostname is always the first positional argument:
# Development — foreground, Ctrl-C kills everything
caddytail run <hostname> <app_ref> [--debug] [--env K=V]
# Production — install as systemd service + tail logs
caddytail install <hostname> <app_ref> [--no-start] [--env K=V]
# Service management
caddytail status <hostname>
caddytail logs <hostname> [-n LINES] [-f]
caddytail restart <hostname>
caddytail uninstall <hostname>
# List all installed services
caddytail list
# Pre-provision Tailscale authentication
caddytail login <hostname> [--auth-key <key>]
# Raw Caddy pass-through
caddytail caddy [args...]The <app_ref> format is module:variable (like uvicorn), defaulting the variable to app:
app:app— importappfromapp.pymyproject.main:application— importapplicationfrommyproject/main.pyapp— shorthand forapp:app
A built-in WSGI file server is included. No code needed — just point it at a directory:
# Foreground
STATIC_PATH=./public caddytail run myfiles caddytail.fileserver:app
# Install as a systemd service
caddytail install myfiles caddytail.fileserver:app --env STATIC_PATH=/srv/filesSTATIC_PATH defaults to . (the working directory). The server provides directory listings and serves index.html when present.
run— starts Caddy + your app in the foreground. Ctrl-C kills everything. The framework is auto-detected: Flask and FastAPI get framework-specific middleware; generic WSGI apps are served withwsgiref; generic ASGI apps are served withuvicorn.install— writes a systemd unit file (ExecStart =caddytail run ...), enables, starts. If stdout is a tty, automatically tails logs. Ctrl-C stops tailing but leaves the service running.uninstall— stops, disables, and removes the unit file.login— authenticates a Tailscale node ahead of time. If already authenticated, returns immediately. Useful for headless provisioning with--auth-key.caddy— passes all remaining args to the bundled Caddy binary.
Returns a TailscaleUser with .name, .login, .profile_pic:
from caddytail import get_user
# Flask — no arguments needed (uses flask.request automatically)
user = get_user()
# FastAPI / Starlette — pass the Request object
user = get_user(request)
# WSGI — pass the environ dict
user = get_user(environ)
# Django — pass request.META
user = get_user(request.META)
if user:
print(user.name) # "John Doe"
print(user.login) # "john@example.com"
print(user.profile_pic) # "https://..."Works as both a Flask decorator and a FastAPI Depends() target:
from caddytail import login_required
# Flask
@app.get("/secret")
@login_required
def secret():
user = get_user()
return f"Hello, {user.name}!"
# FastAPI
@app.get("/secret")
async def secret(user=Depends(login_required)):
return {"message": f"Hello, {user.name}!"}Register static file paths to be served directly by Caddy:
from caddytail import static
static(app, "/assets/*", "./static")
static(app, "/uploads/*", "/var/www/uploads")The runner picks these up automatically when starting Caddy.
For programmatic use (most users should use the CLI runner instead):
from caddytail import CaddyTail
caddy = CaddyTail(app, "myapp", debug=True)
caddy.run()All ports are auto-allocated. No conflicts when running multiple apps.
from fastapi import FastAPI, Request, Depends
from caddytail import get_user, login_required
app = FastAPI()
@app.get("/")
async def index(request: Request):
user = get_user(request)
return {"message": f"Hello, {user.name}!"}
@app.get("/protected")
async def protected(user=Depends(login_required)):
return {"message": f"Hello, {user.name}!"}# views.py
from django.http import HttpResponse
from caddytail import get_user
def index(request):
user = get_user(request.META)
return HttpResponse(f"Hello, {user.name}!")from caddytail import get_user
def app(environ, start_response):
user = get_user(environ)
body = f"Hello, {user.name}!" if user else "Not authenticated"
start_response("200 OK", [("Content-Type", "text/plain")])
return [body.encode()]from caddytail import get_user
async def app(scope, receive, send):
# For ASGI apps, extract headers from the scope manually
...All examples are run the same way:
caddytail run myapp myproject:appPre-built wheels are available for:
| Platform | Architecture |
|---|---|
| Linux (glibc) | x86_64, aarch64 |
| macOS | x86_64 (Intel), arm64 (Apple Silicon) |
| Windows | x86_64 |
git clone https://github.com/jpc/caddytail
cd caddytail
# Install Go and xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# Build caddy with the tailscale plugin
xcaddy build --with github.com/tailscale/caddy-tailscale=github.com/jpc/caddy-tailscale@main --output src/caddytail/bin/caddy
# Build the wheel
pip install build
python -m build --wheelThis project packages Caddy (Apache 2.0 License) with the Tailscale plugin (BSD 3-Clause License).