Date: 2026-05-11
Time: 15:38
This is a production deployment script for expert-service, a web application hosted on Linode behind Caddy as a reverse proxy. It uses FTL2's automation context manager to provision infrastructure (Linode VM, Cloudflare DNS), harden the server (firewall, SSH, SELinux, fail2ban), and deploy the application (Python wheel via uv, systemd service, SQLite database with daily backups). It serves as a real-world example of using FTL2 for both initial provisioning and ongoing redeployment.
Two entry points via CLI:
./deploy.py deploy # Full initial provision: Linode + DNS + hardening + app
./deploy.py redeploy # Update app on existing server: wheel + env + restart
The script uses uv run (PEP 723 inline script metadata) to self-bootstrap dependencies — no virtual environment setup needed. FTL2 and linode-api4 are declared inline.
Core FTL2 pattern — the async with automation(...) context manager:
async with automation(state_file="state.json", secret_bindings={...}) as ftl:
ftl.add_host(hostname=HOST, ansible_host=ip, ansible_user='admin', ansible_become=True)
await ftl[HOST].copy(src=..., dest=..., owner=..., mode=...)
await ftl[HOST].shell(cmd='...')
await ftl[HOST].service(name='...', state='restarted', enabled=True)
Hosts are addressed by name via ftl[HOST], and modules are called as async methods: .copy(), .shell(), .file(), .service(), .user().
Community modules are called on the ftl object directly:
result = await ftl.community.general.linode_v4(label=..., type=..., region=..., image=..., state='present')
await ftl.community.general.cloudflare_dns(zone=..., record=..., type='A', value=ip, state='present')
Environment variables required:
SECRET_KEY — mandatory, will raise KeyError if missingGOOGLECLOUDPROJECT, GOOGLECLOUDLOCATION — GCP config (defaults provided)EXPERTSERVICEAPI_KEY — API authenticationGOOGLECLIENTID, GOOGLECLIENTSECRET — optional, OAuth (conditionally included)LANGFUSESECRETKEY, LANGFUSEPUBLICKEY, LANGFUSE_HOST — optional, observability (conditionally included)Secret bindings (FTL2 feature — maps module parameters to env vars):
LINODETOKEN → community.general.linodev4 access_tokenCLOUDFLAREAPITOKEN → community.general.cloudflaredns apitokenState file: state.json — persists host IP and connection info between deploy and redeploy runs.
Hardcoded paths:
WHEEL — local path to the built Python wheelGCP_CREDENTIALS — local GCP credentials file copied to the serverfulldeploy, the host is first registered as root for initial user creation, then re-registered as admin with ansiblebecome=True for all subsequent operations. This is a pattern for bootstrapping access on fresh VMs.redeploy reads state from disk: It loads state.json to recover the Linode IP rather than querying the Linode API, making redeployment fast and idempotent.deployapp is shared: Both deploy and redeploy call the same deployapp() function, ensuring consistency. It handles: .env file, GCP credentials, data/backup directories, cron job, build deps, wheel upload+install via uv, systemd unit, Caddy config, and verification.becomeuser for unprivileged operations: The uv install and venv creation use becomeuser=SERVICE_USER to run as the expert user rather than root.ss -tlnp) and service status (systemctl status) — good practice for catching deployment failures immediately.env_lines() function conditionally includes config blocks — Langfuse and Google OAuth are only added if their env vars are set locally, making the deployment flexible across environments.copy, shell, file, service, user, waitfor, community.general.linodev4, community.general.cloudflare_dnsexpert-service (a Python web service with SQLite backend, served via Caddy reverse proxy)state.json is written by full_deploy and read by redeploy — they must use the same working directoryWHEEL path to already be built before runningBEEHIVE_KEY for a build server, suggesting CI/CD integration from a machine called "beehive"