Framework Integration Guide¶
lacme integrates with ASGI frameworks to serve HTTP-01 challenge responses from your running web application, eliminating the need for a standalone challenge server on port 80.
Pure ASGI Middleware¶
ACMEChallengeMiddleware works with any ASGI application -- no framework
dependency required. It intercepts requests to
/.well-known/acme-challenge/{token} and serves the key authorization from
the challenge handler. All other requests pass through unchanged.
from lacme.asgi import ACMEChallengeMiddleware
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
# your_app is any ASGI callable
app = ACMEChallengeMiddleware(your_app, handler)
Or use the factory function:
How It Works¶
- A request arrives at the ASGI app
- If the path starts with
/.well-known/acme-challenge/, the middleware looks up the token in theHTTP01Handler - If found, it returns the key authorization with status 200 and content
type
application/octet-stream - If not found, it returns 404
- All other paths pass through to the inner app
Full Example¶
import asyncio
from lacme import Client, FileStore
from lacme.asgi import ACMEChallengeMiddleware
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
async def my_app(scope, receive, send):
"""Simple ASGI app."""
if scope["type"] == "http":
await send({
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/plain"]],
})
await send({
"type": "http.response.body",
"body": b"Hello, world!",
})
# Wrap with ACME challenge handling
app = ACMEChallengeMiddleware(my_app, handler)
# Issue a certificate using the same handler
async def issue_cert():
store = FileStore("~/.lacme")
async with Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue("example.com")
return bundle
Starlette¶
lacme provides dedicated Starlette helpers in lacme.starlette.
Challenge Route¶
Add a route that serves HTTP-01 challenge responses:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import PlainTextResponse
from lacme.starlette import acme_challenge_route
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
async def homepage(request):
return PlainTextResponse("Hello, world!")
app = Starlette(
routes=[
acme_challenge_route(handler),
Route("/", homepage),
],
)
The route handles GET /.well-known/acme-challenge/{token} and returns the
key authorization from the handler, or 404 if the token is not found.
Startup Issuance¶
Issue a certificate when the application starts:
from lacme import Client, FileStore
from lacme.starlette import on_startup_issue
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
store = FileStore("~/.lacme")
client = Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
)
app = Starlette(
routes=[
acme_challenge_route(handler),
Route("/", homepage),
],
on_startup=[
lambda: on_startup_issue(client, "example.com"),
],
)
Note
on_startup_issue is an async function. Starlette's on_startup hooks
accept both sync and async callables.
configure_app()¶
Insert the challenge route into an existing Starlette app at highest priority:
from lacme.starlette import configure_app
app = Starlette(routes=[Route("/", homepage)])
configure_app(app, handler=handler)
# The challenge route is now at position 0 in app.routes
FastAPI¶
lacme provides FastAPI-specific helpers in lacme.ext_fastapi.
Note
Import from lacme.ext_fastapi (not lacme.fastapi) to avoid shadowing
the fastapi package.
Challenge Router¶
Include a router that serves HTTP-01 challenges:
from fastapi import FastAPI
from lacme.ext_fastapi import acme_challenge_router
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
app = FastAPI()
app.include_router(acme_challenge_router(handler))
@app.get("/")
async def homepage():
return {"message": "Hello, world!"}
Client Dependency¶
Use FastAPI's dependency injection to access the lacme client in route handlers:
from fastapi import Depends, FastAPI
from lacme import Client, FileStore
from lacme.ext_fastapi import acme_challenge_router, get_client_dependency
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
store = FileStore("~/.lacme")
client = Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
)
get_client = get_client_dependency(client)
app = FastAPI()
app.include_router(acme_challenge_router(handler))
@app.get("/cert-info/{domain}")
async def cert_info(domain: str, acme: Client = Depends(get_client)):
bundle = store.load_cert(domain)
if bundle is None:
return {"error": "No certificate found"}
return {
"domain": bundle.domain,
"expires_at": bundle.expires_at.isoformat(),
}
Lifespan Issuance¶
Issue a certificate during the FastAPI lifespan startup phase:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from lacme import Client, FileStore
from lacme.ext_fastapi import acme_challenge_router, lifespan_issue
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
store = FileStore("~/.lacme")
client = Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Issue certificate at startup
await lifespan_issue(client, ["example.com", "www.example.com"])
yield
# Clean up on shutdown
await client.close()
app = FastAPI(lifespan=lifespan)
app.include_router(acme_challenge_router(handler))
Complete FastAPI Example¶
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from lacme import Client, FileStore
from lacme.ext_fastapi import (
acme_challenge_router,
get_client_dependency,
lifespan_issue,
)
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
store = FileStore("~/.lacme")
client = Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
)
get_client = get_client_dependency(client)
@asynccontextmanager
async def lifespan(app: FastAPI):
await lifespan_issue(client, "example.com")
task = await client.auto_renew(
interval_hours=12,
days_before_expiry=30,
)
yield
task.cancel()
await client.close()
app = FastAPI(lifespan=lifespan)
app.include_router(acme_challenge_router(handler))
@app.get("/")
async def root():
return {"status": "ok"}
@app.get("/certs")
async def list_certs():
bundles = store.list_certs()
return [
{"domain": b.domain, "expires_at": b.expires_at.isoformat()}
for b in bundles
]
Uvicorn¶
lacme provides helpers to configure Uvicorn's SSL settings from stored certificates.
ssl_kwargs_from_store()¶
Returns a dict suitable for passing to uvicorn.run():
import uvicorn
from lacme import FileStore
from lacme.uvicorn import ssl_kwargs_from_store
store = FileStore("~/.lacme")
# Returns {"ssl_keyfile": "...", "ssl_certfile": "..."}
ssl_kwargs = ssl_kwargs_from_store(store, "example.com")
uvicorn.run(
"myapp:app",
host="0.0.0.0",
port=443,
**ssl_kwargs,
)
Warning
ssl_kwargs_from_store raises FileNotFoundError if no certificate is
stored for the domain, and ValueError if the bundle has no file paths
(e.g., from MemoryStore).
ssl_context_from_store()¶
For custom SSL configurations or non-Uvicorn ASGI servers:
from lacme.uvicorn import ssl_context_from_store
ctx = ssl_context_from_store(store, "example.com")
# ctx is an ssl.SSLContext with TLSv1.2 minimum
TLS Version¶
Both helpers create SSL contexts with TLSv1.2 as the minimum version. This is consistent with modern security requirements and supported by all current browsers and clients.
Complete Uvicorn Example¶
import asyncio
import uvicorn
from lacme import Client, FileStore
from lacme.challenges.http01 import HTTP01Handler
from lacme.uvicorn import ssl_kwargs_from_store
async def issue_cert():
store = FileStore("~/.lacme")
handler = HTTP01Handler()
async with Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
# Start HTTP server for challenges
server = await handler.start_server(port=80)
try:
await client.issue("example.com")
finally:
server.close()
await server.wait_closed()
# Issue cert first (HTTP on port 80)
asyncio.run(issue_cert())
# Then run HTTPS on port 443
store = FileStore("~/.lacme")
uvicorn.run(
"myapp:app",
host="0.0.0.0",
port=443,
**ssl_kwargs_from_store(store, "example.com"),
)
PEM File Helpers¶
Uvicorn only accepts file paths for SSL configuration, not ssl.SSLContext or
in-memory PEM bytes. lacme provides helpers to bridge this gap:
from lacme.mtls import pem_files
with pem_files(bundle, ca_pem=ca.root_cert_pem) as paths:
uvicorn.run("app:app", **paths.as_uvicorn_kwargs())
# temp files cleaned up automatically
For long-lived processes where a context manager is inconvenient:
from lacme.mtls import write_pem_files_persistent
paths = write_pem_files_persistent(bundle, ca_pem=ca.root_cert_pem)
uvicorn.run("app:app", **paths.as_uvicorn_kwargs())
# cleaned up via atexit when process exits
Note
To enforce client certificates (mTLS), also pass
ssl_cert_reqs=ssl.CERT_REQUIRED to uvicorn — it defaults to CERT_NONE
even when ssl_ca_certs is provided.
Django¶
Django is a synchronous framework, so use SyncClient for certificate
management. Since Django does not natively run as an ASGI app, you need to
serve challenge responses manually.
Challenge View¶
Create a Django view that serves HTTP-01 challenge responses:
# views.py
from django.http import HttpResponse, HttpResponseNotFound
from lacme.challenges.http01 import HTTP01Handler
# Create handler at module level (shared state)
handler = HTTP01Handler()
def acme_challenge(request, token):
"""Serve ACME HTTP-01 challenge responses."""
key_authz = handler.get_response(token)
if key_authz is None:
return HttpResponseNotFound("Challenge not found")
return HttpResponse(
key_authz,
content_type="application/octet-stream",
)
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path(
".well-known/acme-challenge/<str:token>",
views.acme_challenge,
),
# ... your other URLs
]
Issue a Certificate¶
Use SyncClient in a Django management command:
# management/commands/issue_cert.py
from django.core.management.base import BaseCommand
from lacme import SyncClient, FileStore
from myapp.views import handler # import the shared handler
class Command(BaseCommand):
help = "Issue a TLS certificate"
def add_arguments(self, parser):
parser.add_argument("domains", nargs="+")
def handle(self, *args, **options):
store = FileStore("~/.lacme")
with SyncClient(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = client.issue(options["domains"])
self.stdout.write(
f"Certificate issued for {bundle.domain}, "
f"expires {bundle.expires_at}"
)
Tip
For production Django deployments, consider issuing certificates separately (via the lacme CLI or a cron job) and configuring your reverse proxy (nginx, Caddy) to use the certificate files from the store directory.