xhost
Sign in

MCP Tools

The 31 tools exposed by the MCP server at https://mcp.xhostd.com/mcp/, grouped by what stage of the app lifecycle they serve.

Auth is OAuth (Google sign-in) for both claude.ai connectors and Claude Code — there is no token to mint or paste. The tools carry the same semantics as the HTTP API; llms-full.txt is the authoritative, always-current tool list. MCP clients cache the tool set at connect time, so if a tool documented here is missing from your session, reconnect the connector to pick it up.

Two argument conventions coexist: lifecycle tools take ids (app_id, channel_id — returned by create_app / list_apps), while data-surface tools take names (app_name, channel, e.g. "prod"). When two accessible apps share a name, qualify it as owner_username/app_name.

GroupTools
Appslist_apps, create_app, get_app, delete_app
Channelslist_channels, create_channel, delete_channel
Files & deploylist_files, read_file, commit_files, deploy, get_deploy_log, sync_git
Env & secretsset_env, delete_env, list_env, get_deploy_env
Database & snapshotslist_channel_snapshots, restore_channel_db
Object storageget_blob_credentials, get_blob_usage
Custom domainsadd_custom_domain, verify_custom_domain, list_custom_domains, remove_custom_domain
Credentials & gitget_credentials
Observabilityget_app_stats, list_activity
Exportsexport_data, get_export_status
Feedbacksubmit_feedback

Apps

The app is xhost's unit of ownership: one git repo (https://git.xhostd.com/<user>/<app>.git) plus one or more channels. These four tools open and close that lifecycle — every other tool operates inside an app they create.

list_apps

Orientation at the start of a session, and the source of the ids the other tools need. Arguments: none.

Returns: {apps: [...]} — each app's id, name, repo_url, template, created_at, and channels (with id, hostname, current_sha, status).

create_app

The first call of any new project. Provisions the git repo and the auto-created prod channel in one step.

ArgumentTypeDescription
namestring requiredDNS label: lowercase, digits, hyphens, max 40 chars. Reserved prefixes rejected: git, api, www, admin, preview, staging.
templatestring optional"static" (default — the committed files are served as-is), "app" (your install.sh + launch.sh run in a Node 22 / Python 3.12 runtime), or "docker" (the Dockerfile at your repo root is built and run; listen on $PORT).

Returns: the app object — id, repo_url, and channels[0], the prod channel, with its id and public hostname (<app>-<user>.xhostd.com), current_sha: null, status: "provisioning". Hold onto app_id and the prod channel_id.

Usage
create_app(name="my-site", template="static")
→ {"id": "f47ac10b-...", "repo_url": "https://git.xhostd.com/alice/my-site.git",
   "channels": [{"id": "7c9e6679-...", "name": "prod",
                 "hostname": "my-site-alice.xhostd.com", "status": "provisioning"}]}

get_app

Fetch a single app by app_id when you need its repo_url or fresh channel status without listing everything.

ArgumentTypeDescription
app_idstring requiredThe app id.

Returns: the app object, same shape as one list_apps entry.

delete_app

Permanent teardown: stops all containers, removes the git repo, and cleans up routes for the app and every channel. This is also the only way to remove the prod channel. Irreversible.

ArgumentTypeDescription
app_idstring requiredThe app id.

Returns: a confirmation string.

How the app sees it. create_app alone puts nothing online — the prod channel sits in provisioning until its first deploy. A running app knows about the platform only through injected env (XHOST_USER, XHOST_SHA, DATABASE_URL, S3_*, PORT). After delete_app, containers stop and the hostnames stop routing.

MCP vs console. The full app lifecycle is available via MCP. Sharing a project with other users (members, roles, ownership transfer) is console-only. A plan_limit_exceeded error is an upgrade prompt, not a retryable failure — upgrading happens in the browser.

Channels

A channel is a deployable environment: bound to one git branch, with its own hostname, its own Postgres schema, its own object-storage bucket, and its own env overrides. prod is created with the app; additional channels give preview and staging URLs (<channel>-<app>-<user>.xhostd.com).

list_channels

Recover channel ids and hostnames for an app later in a session.

ArgumentTypeDescription
app_idstring requiredThe app id.

Returns: an array of channel objects — id, name, hostname, git_ref_binding, current_sha, status.

create_channel

Stand up a preview/staging environment bound to a branch — one explicit channel per branch.

ArgumentTypeDescription
app_idstring requiredThe parent app id.
namestring requiredDNS label. Cannot be prod (auto-created).
git_ref_bindingstring requiredbranch:<name>. The legacy branch:* wildcard is deprecated and rejected at create time.

Returns: the new channel object (status: "provisioning").

Usage
create_channel(app_id, name="staging", git_ref_binding="branch:staging")
→ {"id": "a3bb189e-...", "hostname": "staging-my-site-alice.xhostd.com",
   "status": "provisioning"}

delete_channel

Permanently remove a non-prod channel: tears down its container, route, and Postgres schema. The git repo and other channels are unaffected. Refuses prod — use delete_app for that. Irreversible.

ArgumentTypeDescription
app_namestring requiredThe app's name (as shown in list_apps).
channelstring requiredThe channel name to delete (must not be prod).

Returns: {"ok": true}.

How the app sees it. Each channel is a fully isolated copy of the app: it runs its own container from whatever sha was deployed to it, and its injected DATABASE_URL and S3_* point at that channel's schema and bucket — staging cannot read prod's data.

MCP vs console. Fully available via MCP.

Files & deploy

Shipping is deliberately two-step: a commit stores code, a deploy builds and ships it — committing (or pushing) alone puts nothing live. These six tools cover reading the repo's current state, writing commits without git, and driving builds. For sizable local checkouts, plain git push via get_credentials is the faster path — the deploy step is the same either way.

list_files

See what is in the repo before editing — essential for a stateless agent.

ArgumentTypeDescription
app_idstring requiredThe app id.
refstring optionalBranch name or 40-char sha. Default "master".

Returns: {ref, sha, files: [{path, kind, size}]}.

read_file

Fetch one file's contents before modifying it.

ArgumentTypeDescription
app_idstring requiredThe app id.
pathstring requiredRepo-relative file path.
refstring optionalBranch name or sha. Default "master".

Returns: the file contents as text.

commit_files

Write files as one real git commit, no local git required. Sparse: send only what's changing.

ArgumentTypeDescription
app_idstring requiredThe app id.
messagestring requiredCommit message (non-empty).
filesobject requiredMap of path → content. A string upserts the file, null deletes it, absent paths are unchanged.
refstring optionalBranch to commit on. Default "master"; created if it doesn't exist.

Returns: {sha} — pass it to deploy. On GitHub-connected apps this returns an error; push to GitHub instead.

deploy

The build-and-ship trigger. Runs asynchronously; follow progress with get_deploy_log.

ArgumentTypeDescription
app_idstring requiredThe app id.
channel_idstring requiredThe target channel id.
shastring optionalA 40-char hex commit. Exactly one of sha/ref is required; if both are supplied, sha wins.
refstring optionalA branch name ("master" or "refs/heads/master") — resolved to that branch's current HEAD at deploy time.

Returns: {deploy_id, channel_id, status: "queued"}. On GitHub-connected apps, each deploy first auto-syncs the mirror from GitHub; a failed sync rejects the deploy with the sync error. Docker-template deploys stream [build] ... lines (queue position, build duration, image size vs the plan cap) into the deploy log.

get_deploy_log

The plain-text build/run log — poll it until the deploy reaches success or failed. The most common first-deploy failure reads health check returned non-200: the app didn't answer HTTP 200 at / on $PORT in time. For docker deploys the log also carries [build] ... lines — queue position, build duration, and the built image's size against the plan cap.

ArgumentTypeDescription
app_idstring requiredThe app id.
channel_idstring requiredThe channel id.
deploy_idstring requiredThe deploy id from deploy.

Returns: the log as plain text.

sync_git

For apps connected to a GitHub source: fetch the latest GitHub commits into the app's xhost mirror without deploying. Deploys auto-sync anyway, so this is for refreshing the mirror or surfacing sync errors on their own.

ArgumentTypeDescription
app_idstring requiredThe app id. Errors if no GitHub source is connected.

Returns: the mirror status — {last_sync_status, last_sync_error, last_sync_refs, last_synced_at}.

Usage — the golden path
commit_files(app_id, message="add landing page",
             files={"index.html": "<!doctype html>..."})
→ {"sha": "def456..."}
deploy(app_id, channel_id, sha="def456...")
→ {"deploy_id": "9b1deb4d-...", "status": "queued"}
get_deploy_log(app_id, channel_id, deploy_id="9b1deb4d-...")  # poll until success
How the app sees it. A successful deploy is a new container running exactly the committed sha (injected as XHOST_SHA). For the static template the committed files are served straight from the repo root. For the app template, install.sh runs, then launch.sh; the server must bind 0.0.0.0:$PORT and answer HTTP 200 at / within 120s, inside a ~128 MB memory budget that applies to install.sh too. For the docker template, the Dockerfile at the repo root is built on the app's cell and the container runs with its own ENTRYPOINT/CMD; it must listen on $PORT and answer the same GET / health check. Env vars are injected at run time only — never as build args, so secrets are unavailable during the build. Charged image size (total minus warm-base layers) is capped per plan — free 512 MiB / basic 2 GiB / pro 4 GiB — with the warm bases (node:22-slim, node:24-slim, python:3.11-slim, python:3.12-slim, python:3.13-slim, debian:trixie-slim) exempt from the cap.

MCP vs console. All six tools are MCP. Connecting or disconnecting a GitHub source has no MCP tool — use the console or the HTTP API (POST /apps/{app_id}/github/connect, DELETE /apps/{app_id}/github); while connected, commit_files and git push to xhost are rejected — GitHub is the source of truth.

Env & secrets

Runtime configuration without hardcoding it in the repo. Two kinds: env (plain, readable back) and secret (write-only through MCP). Two scopes: an app-level default shared by all channels, and a per-channel override that wins at deploy time. All values are encrypted at rest, and every change takes effect at the next deploy.

set_env

Create or update a variable or secret.

ArgumentTypeDescription
app_idstring requiredThe app id.
keystring requiredMust match ^[A-Z_][A-Z0-9_]*$. System-injected keys are reserved and rejected: XHOST_USER, XHOST_SHA, DATABASE_URL, DATABASE_HOST, DATABASE_PASSWORD, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION.
valuestring requiredThe value; stored encrypted.
secretboolean optionalDefault false. With true the value is a secret: never readable back through MCP. The HTTP API reveal endpoint (GET /apps/{app_id}/env/{key}/value) and the console's click-to-reveal can return it, and each reveal is audit-logged.
channelstring optionalA channel name (e.g. "prod"). With it, the value is a channel-scoped override that beats the app-level default on that channel; without it, the app-level default.

Returns: a confirmation string.

delete_env

Remove a variable or secret.

ArgumentTypeDescription
app_idstring requiredThe app id.
keystring requiredThe key to delete.
channelstring optionalWith a channel name, deletes only that channel's override — the app-level default, if any, stays in effect. Without it, deletes the app-level entry.

Returns: a confirmation string.

list_env

Inspect current configuration. Plain values come back in cleartext; secret values are never returned via MCP — secret entries carry metadata only (value is null).

ArgumentTypeDescription
app_idstring requiredThe app id.
channelstring optionalWithout it: every raw entry (app-level defaults and all channel overrides). With a channel name: the resolved view for that channel, with scope (app or channel) reporting which entry wins at deploy time.

Returns: {env: [{key, kind, scope, channel_id, updated_at, value}]}.

get_deploy_env

Debugging "what config was live when this broke": the env snapshot recorded when a past deploy started — what the app actually ran with, not what is configured now.

ArgumentTypeDescription
app_idstring requiredThe app id.
channelstring requiredThe channel name.
deploy_idstring requiredThe deploy id.

Returns: user-set plain vars with values, secrets masked (value: null), and system-injected keys listed by name only — their values are credentials and are not stored in the snapshot. 404 for deploys that predate env snapshots.

Usage
set_env(app_id, key="STRIPE_SECRET_KEY", value="sk_live_...", secret=True)
set_env(app_id, key="LOG_LEVEL", value="debug", channel="staging")
deploy(app_id, channel_id, ref="master")   # changes take effect here
How the app sees it. Values appear in the process environment (process.env.MY_VAR, os.environ["MY_VAR"]) at the next deploy — never mid-run. Resolution order at deploy time: system-injected keys beat channel overrides, which beat app-level defaults.

MCP vs console. Editing env is deliberately MCP/API-only — the console displays each channel's resolved env but doesn't edit it. Revealing a secret's value is the one thing that goes the other way: there is no MCP tool, but the HTTP API endpoint GET /apps/{app_id}/env/{key}/value and the console can reveal it, and every reveal is audit-logged.

Database & snapshots

Every channel automatically gets its own Postgres schema with DATABASE_URL injected — there is nothing to enable and no tool to call for normal use. Before every non-static deploy the platform snapshots the channel's schema; these two tools are the safety net for when a deploy's migration goes wrong.

list_channel_snapshots

Enumerate the automatic pre-deploy snapshots, newest first.

ArgumentTypeDescription
app_namestring requiredThe app's name (as shown in list_apps).
channelstring requiredThe channel name.

Returns: a list of {snapshot_id, deploy_id, created_at, size_bytes}, newest first.

restore_channel_db

Roll a channel's schema back to a snapshot. The restore is staged: the live schema is renamed aside and only dropped after the restore succeeds, so a failed restore loses nothing. Refuses prod unless the app's env contains XHOST_ALLOW_PROD_RESTORE=1, and refuses while any deploy on the channel is queued or running.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.
snapshot_idstring requiredFrom list_channel_snapshots.

Returns: the channel's updated Postgres status.

Usage — undo a bad migration
list_channel_snapshots(app_name="my-site", channel="staging")
→ [{"snapshot_id": "5f2c...", "deploy_id": "9b1d...",
    "created_at": "2026-07-01T09:00:00Z", "size_bytes": 81920}, ...]
restore_channel_db(app_name="my-site", channel="staging", snapshot_id="5f2c...")
How the app sees it. The app reads DATABASE_URL from its environment; the connection's search_path is pinned to the channel's schema, so code uses unqualified table names. Migrations are 100% user-managed — put alembic upgrade head / prisma migrate deploy in install.sh (the database is reachable before it runs). After a restore, the same DATABASE_URL keeps working; the data is simply back at the snapshot moment.

MCP vs console. Snapshot list/restore is MCP (with the prod guard above). SQL reset (drop + recreate the schema) and SQL dump download have no MCP tool — they are HTTP API endpoints (also surfaced in the console) meant for deliberate, human-approved use. The per-project external database access toggle that lets outside tools connect via db.xhostd.com is console-only.

Object storage

Per-channel S3-compatible storage for unstructured blobs (uploads, generated assets) — auto-provisioned like the database, no enable step. A running app needs no tool at all: its credentials are injected. These two tools exist for working with a channel's bucket from outside the container, and for monitoring.

get_blob_credentials

Fetch the endpoint, region, bucket, and key pair for any S3 client. The secret access key is sensitive — treat it like a password. Each read is audit-logged. Accessing the bucket from outside xhost additionally requires the console's external-access toggle to be on.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.

Returns: a rendered configuration block — endpoint, region, bucket, access key ID, secret access key.

get_blob_usage

Check consumption before it hits the per-user quota (over quota, writes return 507).

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.

Returns: bytes used and the provisioning status (plus the last error if failed).

How the app sees it. Every deploy injects S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION — point any S3 SDK at those env vars rather than constructing values. Object versioning is always on, a channel's key can only address its own data, and each deploy records a snapshot marker that a restore (console or API) can roll back to.

MCP vs console. Credentials and usage are MCP. Storage snapshot restore has no MCP tool — it is an HTTP API endpoint (POST .../blob/restore) also surfaced in the console; the app-wide external-access toggle is console-only. A data rollback and opening storage to the outside are deliberate human actions.

Custom domains

Channels get canonical *.xhostd.com hostnames automatically; these four tools put a channel on the user's own domain instead. Up to 5 domains per channel; domains are globally unique across xhost. The flow is: attach → the user creates DNS records at their registrar → verify. See Custom domains for the full story.

add_custom_domain

Attach a domain and get the DNS records the user must create: a TXT ownership token plus a routing record (CNAME for subdomains, A for an apex). The result includes a ready-to-relay instructions string — pass it to the user verbatim. Re-adding the same domain on the same channel is idempotent (token preserved); domain_taken (409) means another channel owns it.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.
domainstring requiredThe hostname to attach (e.g. myapp.com). Invalid-domain reasons: is_ip_address, invalid_idna, too_long, invalid_label, platform_domain_forbidden, needs_dot.

Returns: the domain object — {domain, status: "pending", reason, dns_records: {txt_host, txt_value, cname_target, a_values}, created_at, verified_at} — plus instructions.

verify_custom_domain

Re-check the DNS records after the user creates them. Idempotent and retryable — propagation usually takes a few minutes, so a pending status with a transient reason on the first call is normal. On a passing check the status flips to verified and the public route plus on-demand TLS go live. A verified domain only downgrades on the four definitive reasons (txt_nxdomain, txt_token_mismatch, dns_not_pointing, domain_nxdomain); transient lookup failures never downgrade it.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.
domainstring requiredThe attached hostname.

Returns: the same domain-object shape, with fresh status/reason and instructions.

list_custom_domains

See which domains are verified and which still need DNS work — check the count before proposing another add_custom_domain (limit 5).

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.

Returns: {domains: [...]}, each item the same shape as add_custom_domain's result.

remove_custom_domain

Detach a domain. The route is removed, certificate renewals stop, and the existing cert expires on its own — nothing to do at the registrar.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring requiredThe channel name.
domainstring requiredThe hostname to detach.

Returns: {ok: true}.

Usage
add_custom_domain(app_name="my-site", channel="prod", domain="example.com")
→ instructions: "Add these DNS records at your registrar:
     TXT  _xhost.example.com  xhost-verify-abcdef01...
     A    example.com         198.51.100.7"
# ...user creates the records, a few minutes pass...
verify_custom_domain(app_name="my-site", channel="prod", domain="example.com")
→ {"status": "verified", ...}
How the app sees it. Nothing changes inside the app: traffic to a verified custom domain arrives at the same container on the same $PORT. HTTPS is automatic — the certificate mints on the first request after verification. Google sign-in works on the custom domain with no extra config; the identity cookie's aud is the inbound hostname, so a login on myapp.com is separate from the canonical channel host.

MCP vs console. The full domain lifecycle is available via MCP; the console offers the same operations as a convenience.

Credentials & git

MCP sessions are authenticated by OAuth — no token ever surfaces. When the work steps outside MCP (a local git push, psql, curl against the API), this tool mints the one credential that covers all three.

get_credentials

Mint a 30-day unified credential. Arguments: none. The token is simultaneously the git password, the Postgres password (when external database access is enabled in the console), and a platform API bearer, carrying the full default scopes. Revocable any time on the console's /tokens page; re-mint by calling the tool again after expiry.

Returns: {token, username, expires_at, scopes}.

Usage — local git push, then deploy
get_credentials()
→ {"token": "xh_...", "username": "alice", "expires_at": "...", "scopes": [...]}

git remote add xhost "https://alice:xh_...@git.xhostd.com/alice/my-site.git"
git push xhost master

deploy(app_id, channel_id, ref="master")   # pushing alone does not deploy
How the app sees it. It doesn't — this is an operator credential, never injected into the container. Never commit it into the repo or write it into a file that might be checked in.

MCP vs console. Mintable via MCP or on the console tokens page. The underlying POST /credentials endpoint also accepts an optional scopes subset for a least-privilege credential.

Observability

Two read-only views: what the app has been doing (traffic and usage), and what people and agents have been doing to it (an attributed audit trail). Consult these before recommending scale/perf changes, or when reconstructing "who changed what".

get_app_stats

Access/usage stats over a recent window. Timestamps are UTC.

ArgumentTypeDescription
app_namestring requiredThe app's name.
channelstring optionalA channel name. When omitted, stats are summed across all channels.
windowstring optionalOne of "24h" (default), "7d", "30d".

Returns: the stats object for the window.

list_activity

The project's audit trail, newest first: member changes, deploys, env writes, database operations, git pushes and commits — each attributed to the user who did it. No secret values ever appear.

ArgumentTypeDescription
app_namestring requiredThe app's name.
limitinteger optionalMax events to return, 1–100. Default 50.

Returns: {events: [{id, actor_username, action, target, detail, created_at}], next_before}.

How the app sees it. No effect — both tools are pure reads.

MCP vs console. Both available via MCP; the console shows the same stats and activity on the project pages.

Exports

Portable takeout — no lock-in. An export is a self-contained archive of the deployed code, a Postgres schema dump, and (when small enough) the channel's object-storage files, reloadable with standard tools and no xhost. Env variable keys are included as blank placeholders; secret values are never exported. Builds run asynchronously: queue, then poll.

export_data

Queue an export of one channel or a whole app.

ArgumentTypeDescription
scopestring required"channel" (one channel) or "app" (every channel).
appstring requiredThe app's name.
channelstring optionalThe channel name — required when scope is "channel", ignored for "app".

Returns: {export_id, status, detail}.

get_export_status

Poll an export by id. While building, it reports progress; when ready, it mints a short-lived download token and returns ready-to-paste curl commands for the archive (and, when included, a separate one for the object-storage files); when failed, it surfaces the error.

ArgumentTypeDescription
export_idstring requiredFrom export_data.

Returns: {status, detail, progress_pct, size_bytes, blobs_included, blobs_reason, expires_at}, plus download instructions when ready or error when failed.

Usage
export_data(scope="channel", app="my-site", channel="prod")
→ {"export_id": "e1a2...", "status": "queued"}
get_export_status(export_id="e1a2...")   # poll until "ready"
→ {"status": "ready", "size_bytes": 1048576, "download": "curl ..."}
How the app sees it. No effect on the running app — an export is a read-side copy.

MCP vs console. Available in both: the console project page has the same trigger, status, and download links.

Feedback

The agent driving these tools sees the rough edges first, so it gets a direct channel to the xhost team. Fire-and-forget — no permission needed, no result to block on.

submit_feedback

Call proactively whenever something gets in the way: a task that took several iterations, an unclear tool or error, a hard-to-diagnose failure, a missing capability.

ArgumentTypeDescription
messagestring requiredThe feedback text, in your own words. Non-empty, max 4000 characters.
app_idstring optionalThe app being worked on, for context. An unknown id is silently dropped; the feedback still lands.

Returns: {id, status: "new"}.

How the app sees it. No effect on any app.

MCP vs console. MCP/API only — it exists precisely for the agent in the loop.

MCP vs console matrix

Almost everything is agent-drivable. The exceptions are deliberate: access-widening toggles and project sharing are console-only, and destructive data actions have no MCP tools — they exist as HTTP API endpoints (and in the console) meant for deliberate, human-approved action, not something an agent triggers mid-session.

CapabilitySurface
Create/delete apps and channels, commit, deploy, logsMCP + API
Env & secrets: set, delete, list, deploy snapshotsMCP + API (console views only)
Secret value reveal (each reveal audit-logged)API + console (no MCP tool)
Database snapshots: list & restore (prod gated by XHOST_ALLOW_PROD_RESTORE=1)MCP + API
SQL reset (drop + recreate schema)API + console (no MCP tool)
SQL dump downloadAPI + console (no MCP tool)
Object storage: credentials & usageMCP + API
Object-storage snapshot restoreAPI + console (no MCP tool)
External-access toggles (database + object storage)Console only
Custom domains: add, verify, list, removeMCP + API
Project sharing: members, roles, ownership transferConsole only
GitHub source: connect & disconnect (sync via sync_git)API + console (no MCP tool)
Credentials, stats, activity, exports, feedbackMCP + API

The pattern: wiping a schema, rolling storage back, revealing a secret, or opening a data surface to the outside world all have a blast radius that warrants a deliberate human decision — so none of them are MCP tools, and secret reveals carry an audit trail attributing the action to the user who took it. For everything an app needs day to day, write code that reads the injected DATABASE_URL and S3_* env instead of driving those surfaces directly.