Browse docs All docs
Docs / Source / file storage reference
Guidedeploymill://guides/source

Source / file storage

Where a DeployMill project's files live, how an agent reads and writes them, and how the storage backend is modeled so it can be swapped without changing any tool.

The model: source is a provider-neutral primitive

DeployMill has five provider-neutral primitives: compute, database, domain, secrets, and source. "Source" is where your project's files live and the git history behind them. Like the others, it's an interface with a swappable backend, not a hardcoded vendor.

  • Today (v1) there is exactly one source backend: GitHub. A project is a GitHub repo; the platform builds from it via the server's configured GitHub App provider. This is the default and it just works. You don't configure anything.
  • The backend is selected by the server's SOURCE_PROVIDER env var (default github). A repo can also record its own backend in .deploymill/project.json (see below), so a future second backend slots in behind the same tools. The most likely is a self-hosted Gitea/Forgejo instance with one namespace per tenant, so users get git without needing a GitHub account.

As an agent you don't pick a provider. Use the file tools and they talk to whatever backend the server runs.

The file tools (work against any backend)

These are provider-neutral by construction. They take a repo name, branch, and path, never anything GitHub-specific:

  • push_files: commit one or more files in a single atomic commit. Pass deploy: { applicationId } to ship the commit through a health-gated deploy in the same call (a bare commit no longer auto-deploys). Pass createBranchIfMissing: true to create the branch first.
  • get_file: read one file's decoded contents.
  • list_files: list a directory (repo root by default).
  • start_project / import_repo create or adopt the repo for you.

There's no standalone branch-create tool: push_files (createBranchIfMissing) and create_preview (createBranchIfMissing / createBranchFrom) each create the target branch on demand, so the branch-then-commit and branch-then-preview flows are one call.

Repos are created/resolved under the server's configured default owner/namespace; you only supply the repo name.

The source block in .deploymill/project.json

Optional. Records which backend a repo lives on so reconcile_project, import_repo, and previews know where to read from and how to bind the app for builds.

{
  "version": 2,
  "name": "my-app",
  "domains": { "prod": "my-app.example.com" },
  "mounts": [],
  "rollback": false,
  "source": { "provider": "github" }   // optional; omit ⇒ github (the default)
}

Fields:

  • provider: "github" (the only wired backend in v1), "gitea", or "external". The latter two are reserved: the contract is defined so a repo can declare them, but the backends aren't implemented yet. Declaring one today raises a clear "not available on this server" error rather than silently writing to the wrong place.
  • owner, repo, url: optional hints. For GitHub the location is already implied by where the file lives, so start_project writes just { provider }. url is the slot a bring-your-own external git remote would use.
  • connection: optional opaque id of the source connection the repo lives under (a bring-your-own connected account). Omit ⇒ your workspace's default connection (the shared house account until you set a different default). It names a source_connection row, not a GitHub concept.

Backward compatibility: a config with no source block means GitHub. Older files written before this field keep working unchanged.

Self-healing stale connection ids (import). A source.connection (or database.connection) id can go stale, most often when you disconnect and reconnect the underlying account, which mints a new connection id while the old one is still written in the repo's project.json. Rather than fail, import_repo validates the declared ids and recovers the right connection: for source, the connection whose account owns the repo's owner, else the workspace default; for database, the workspace default for that provider. The import proceeds either way, and, with your confirmation (it asks via an elicitation prompt, or auto-applies when the client can't be asked), commits the corrected project.json back to the repo so the fix sticks. The result's healed block reports what changed. Pass autofix: false to recover at runtime without writing to the repo.

How the platform builds from source (background)

When an app is created, the source provider translates the repo binding into the compute backend's clone instructions. For GitHub that's the configured GitHub App provider (the platform pulls owner/repo@branch). A generic-git backend would instead hand the builder a clone URL plus a deploy key. This split lives entirely behind the provider interface (no tool signature encodes it), which is what keeps the source primitive swappable.

Editing files: two flows

There are two ways files get written, and you can use either.

1. API commits via MCP tools (no checkout). push_files commits straight through the backend's API in one atomic commit; get_file / list_files read. This is the primary DeployMill loop. Pass deploy: { applicationId } to ship the commit through DeployMill's health-gated deploy in the same call (a bare commit doesn't deploy). It needs no clone and works the same across any backend. Text files only, ≤10MB total: each file's content is a UTF-8 string, so push_files is for source code and text assets, not binary files, and not large/bulk uploads (see below).

2. A real git clone + push (working tree). When you (or a local coding agent) want an actual checkout to build/test against, or need to commit binary or large files, call get_clone_credentials({ repo, write?, connection? }). It returns a short-lived (≤1 hour), repo-scoped authenticated clone URL:

git clone https://x-access-token:<token>@github.com/<owner>/<repo>.git

Clone, commit (your local git config sets the author), push for the token's lifetime, and re-call the tool for a fresh token when it expires. The token is scoped to the single repo with just contents permission and is revocable. Treat the URL as a secret (it embeds the token).

JSON files (package.json, etc.)

When committing a JSON file such as package.json, the content field must be passed as a string, not as a parsed JavaScript/JSON object. Some MCP clients (and AI agents that read a JSON file and hand the parsed value straight back) may pass the value as an object rather than its serialized form, which previously caused Zod validation to fail silently.

As of this fix, push_files auto-coerces non-string content values (objects, numbers, booleans) to their JSON string form via JSON.stringify, so agents no longer need to serialize explicitly. Passing the content as a string (e.g. JSON.stringify(pkgJson, null, 2)) still works and is the safest form for complex setups or when you need to control whitespace. For very large JSON files or when you want to commit many files at once, the get_clone_credentials + git push flow remains the most flexible option.

Binary and large files: use clone + push, not push_files

push_files takes each file's content as a UTF-8 text string, so it can only handle text. Reach for the git clone flow (#2 above) whenever either is true:

  • Binary content: images, fonts, icons, PDFs, audio/video, archives (.zip/.tar), or any compiled/build artifact. Binary bytes cannot round-trip through a JSON string and will be corrupted if forced through push_files.
  • Large or bulk uploads: anything over the 10MB total push_files cap, or a big tree of files where a working-tree git add is simpler and cheaper than enumerating every file as a tool argument.

In both cases: get_clone_credentials({ repo, write: true })git clone the returned URL → add/commit your files locally → git push. The working-tree flow handles arbitrary bytes and large trees natively. (push_files will also reject an over-10MB commit with an error that points you here.)

Tenancy: where repos live and namespacing

In the hosted model, repos live under a single DeployMill-owned GitHub organization (not anyone's personal account), accessed via a GitHub App rather than a shared token. To keep tenants from colliding on a repo name, repo names are prefixed with the tenant's org slug (<orgSlug>_<name>) when SOURCE_NAMESPACE_REPOS is enabled. The prefixed name is the repo's real name. start_project returns it, and that's the name you pass to push_files, get_file, get_clone_credentials, etc. The delimiter is an underscore (_), which is illegal in a slug, so the prefix is an unambiguous tenant boundary.

get_clone_credentials enforces tenant isolation off this prefix: through the shared house account you can only mint credentials for repos under your own <orgSlug>_ namespace. If the repo lives under one of your own connected GitHub accounts (bring-your-own source), either because an app is bound to it or you pass an explicit connection, that account is the isolation boundary, so the <orgSlug>_ prefix doesn't apply and the token is minted against your connection's installation.

Users who already have a GitHub account and want native, persistent access (their own identity on commits, PRs) can be added as a per-repo collaborator, an opt-in that doesn't change the default no-GitHub-account-required flow.

What to do as an agent

  • Just use push_files / get_file / list_files. They work regardless of backend.
  • For binary files (images, fonts, PDFs, archives, build artifacts) or large/bulk uploads (>10MB), don't use push_files. It's text-only and capped. Use get_clone_credentials + a real git clone + push instead.
  • Leave the source block out of project.json unless you have a reason to pin a non-default backend. Omitting it = GitHub.
  • If a tool returns a "source provider … is not available" error, the repo's config declares a backend the server hasn't enabled. Fix the source.provider field or ask the operator which backend is configured.