Django + Vercel
A full Django app deployed to Vercel with vsync-s3-client reading secrets from S3 at boot. The two env vars (VSYNC_CONFIG, VSYNC_PASSPHRASE) live in Vercel's Environment Variables UI as Sensitive Variables.
Stack
- App: Django 5.x + Gunicorn
- Platform: Vercel (Python runtime)
- Vault: S3-compatible bucket (this example uses AWS S3
eu-central-1; any provider works) - Lib:
vsync-s3-client0.11.0
Working directory tree
my-django-app/
├── infra/
│ └── vault/ (gitignored)
│ └── prod/
│ ├── .env.prod (real prod secrets — never committed)
│ └── gcp-sa.json (any binary file you need at runtime)
├── myproject/
│ ├── settings.py (reads from vsync at module import)
│ ├── urls.py
│ ├── wsgi.py
│ └── __init__.py
├── app/ (Django app folders)
├── manage.py
├── requirements.txt
├── vercel.json
├── .env (symlink → infra/vault/<env>/.env.<env>, gitignored)
└── .gitignoreOne-time setup
1. Init the env on a teammate's laptop
cd my-django-app
vsync profile add acme-prod # if you don't have a profile yet
vsync init prod --profile=acme-prod
# infra/vault/prod/ is created. Drop your secrets in:
cat > infra/vault/prod/.env.prod <<'EOF'
DJANGO_SECRET_KEY=...
DATABASE_URL=postgres://...
SENTRY_DSN=https://...@sentry.io/...
DEBUG=False
ALLOWED_HOSTS=myapp.com,www.myapp.com
EOF
vsync push prod --note="initial prod setup"2. Mint the runtime token
The IAM key in this blob should be read-only, scoped to the bucket-prefix myproject/prod/*. See the IAM key shape.
vsync runtime-token --env=prod \
--access-key=AKIA_PROD_READONLY \
--secret-key=PROD_READONLY_SECRET
# Output: vsync-cfg-v1:H4sIAAAA...3. Paste into Vercel
In your Vercel project:
- Settings → Environment Variables.
- Add
VSYNC_CONFIG— paste the blob — mark Sensitive. - Add
VSYNC_PASSPHRASE— paste the passphrase fromvsync init— mark Sensitive. - Scope: Production (or whichever Vercel env corresponds to your vsync env).
Sensitive Variables are write-only — you can't read them back from the UI, only update them. Good hygiene for both VSYNC_CONFIG (contains the IAM key) and VSYNC_PASSPHRASE.
Code
requirements.txt
Django>=5.0,<6
gunicorn>=22.0,<23
dj-database-url>=2.1,<3
psycopg[binary]>=3.1,<4
vsync-s3-client>=0.11.0,<0.12
sentry-sdk>=2.0,<3myproject/settings.py
import os
from pathlib import Path
import dj_database_url
import sentry_sdk
import vsync_s3_client
BASE_DIR = Path(__file__).resolve().parent.parent
# Open vsync once at module import. Don't close — Django re-imports settings
# in odd places, and a closed handle here surfaces as a hard-to-debug error.
_v = vsync_s3_client.open(defaults={
"DEBUG": "False",
"ALLOWED_HOSTS": "localhost,127.0.0.1",
})
# Secrets from vault first, env second, defaults last, missing = None
SECRET_KEY = _v.get_env("DJANGO_SECRET_KEY")
if SECRET_KEY is None:
raise RuntimeError("DJANGO_SECRET_KEY is not set in vault or env")
DEBUG = _v.get_env("DEBUG") == "True"
ALLOWED_HOSTS = _v.get_env("ALLOWED_HOSTS").split(",")
DATABASES = {
"default": dj_database_url.parse(_v.get_env("DATABASE_URL"))
}
# Materialize the GCP service account JSON to a tempfile (3-line recipe per spec)
gcp_bytes = _v.get_as_content("gcp-sa.json")
if gcp_bytes:
import tempfile
tf = tempfile.NamedTemporaryFile(prefix="gcp-sa-", suffix=".json",
delete=False, mode="wb")
tf.write(gcp_bytes)
tf.close()
os.chmod(tf.name, 0o600)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = tf.name
# Sentry
sentry_sdk.init(
dsn=_v.get_env("SENTRY_DSN"),
environment="production",
send_default_pii=False,
)
# Log where each setting came from (safe — env_source never returns the value)
import logging
log = logging.getLogger(__name__)
log.info("vsync gen=%d, DATABASE_URL source=%s, SECRET_KEY source=%s",
_v.generation(),
_v.env_source("DATABASE_URL"),
_v.env_source("DJANGO_SECRET_KEY"))
# ... rest of standard Django settings ...
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"app",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
ROOT_URLCONF = "myproject.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": []},
}]
WSGI_APPLICATION = "myproject.wsgi.application"
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"app/views.py — /healthz endpoint
from django.conf import settings
from django.http import JsonResponse
from vsync_s3_client import S3UnreachableError, ManifestNotFoundError
def healthz(request):
v = settings._v
try:
if v.has_new_version():
return JsonResponse({
"status": "stale",
"local_gen": v.generation(),
"remote_gen": v.remote_generation(),
})
return JsonResponse({"status": "fresh", "gen": v.generation()})
except (S3UnreachableError, ManifestNotFoundError):
# Transient — don't trigger a Vercel redeploy via healthcheck failure
return JsonResponse({"status": "unknown", "gen": v.generation()})myproject/urls.py
from django.urls import path
from app.views import healthz
urlpatterns = [
path("healthz", healthz),
# ... your routes ...
]Deployment manifest — vercel.json
{
"version": 2,
"builds": [
{ "src": "myproject/wsgi.py", "use": "@vercel/python" }
],
"routes": [
{ "src": "/(.*)", "dest": "myproject/wsgi.py" }
],
"env": {
"PYTHONUNBUFFERED": "1"
}
}VSYNC_CONFIG and VSYNC_PASSPHRASE come from the dashboard (Sensitive Variables), not from vercel.json. Keeping them out of the manifest means they're not in your repo's git history if anyone ever accidentally commits vercel.json with real values.
After rotation — Vercel-specific
When you run vsync rotate-passphrase --env=prod:
- Update
VSYNC_PASSPHRASEin Settings → Environment Variables (Sensitive Variables flow). - Vercel does not automatically redeploy on env-var change for Sensitive Variables — trigger a redeploy yourself:
vercel --prodfrom your laptop, or click Redeploy in the dashboard. - The new deployment reads the new passphrase. Old deployments staying alive (Vercel keeps the previous Production deployment serving until the new one is ready) still have the old passphrase in memory — they're fine until the new one is promoted.
Race window is tighter on Vercel than ECS because the platform manages the cutover atomically.
Things to watch out for
- Cold starts add ~150-500ms of vsync
open()time to the first request. On Vercel's Python runtime this is in addition to the cold-start hit Django itself imposes. If cold-start latency is critical, pre-warm via a scheduled health check or move to a service that supports always-warm instances. - Sentry will auto-capture
process.envon crashes. Configure Sentry'sevent_processorto redactVSYNC_CONFIGandVSYNC_PASSPHRASE. Therepr(v)of the vsync handle is already opaque, but the env vars themselves are still inprocess.env. DEBUG=Truein vault accidentally leaks here. If a teammate pushesDEBUG=Truefor testing and forgets to revert, your prod deployment will run in debug mode. Add an explicitif ENV == "production" and DEBUG: raise RuntimeError(...)guard.- Local dev doesn't need the runtime token — set
VSYNC_CONFIG_FILE/VSYNC_PASSPHRASE_FILEpointing at local files, or usevsync use devanddotenv.config()the way you would without vsync. gcp-sa.jsonmaterialization happens at module import. If the GCP SDK reads the env var lazily (at first request), the tempfile is still there — Python doesn't auto-delete unless youatexit.register(os.unlink, tf.name).
Local development
Don't try to ship a runtime token for local dev. Use the regular vsync workflow:
# On the developer's laptop, with `vsync use dev` already run:
python manage.py runserverIn settings.py, fall back to plain dotenv reads when VSYNC_CONFIG is unset:
import os
import vsync_s3_client
if os.getenv("VSYNC_CONFIG"):
_v = vsync_s3_client.open(defaults={"DEBUG": "False"})
SECRET_KEY = _v.get_env("DJANGO_SECRET_KEY")
# ...
else:
# Local dev — read from ./.env (symlinked by `vsync use dev`)
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
# ...This is the recommended split: vsync runtime libs for deployed environments, plain dotenv via vsync use for local development. Same vault, two access paths.
Where to go next
- Python lib reference: Python
- Mint a token: Runtime tokens
- Rotate the passphrase: Rotate-passphrase runbook
- Other Python recipe: FastAPI + Cloud Run