Skip to main content

Build from Source

Instead of supplying a prebuilt image, you can point an app at a git repository. The platform clones the repository, builds the image on your app's own VM, pushes it to the platform registry, and deploys it through the same health-gated blue/green flow as any other deploy. A repository with a Dockerfile is built from it; a repository without one is built automatically with Cloud Native Buildpacks, so no Dockerfile is required.

A source build works like this:

  1. Clone. The builder fetches the repository at the ref you configured (branch, tag, or commit; the default branch when unset) and resolves the exact commit SHA.
  2. Build. The image is built on your app's own single-tenant VM: from your Dockerfile with Buildah when the repository has one, otherwise with Cloud Native Buildpacks, which detect the language and produce a runnable image without a Dockerfile. Nothing from your source or build ever runs on shared compute.
  3. Push. The image is pushed to the platform registry (registry.foundrydb.com). The registry is private, and each app pushes to its own repository; no other tenant can pull your images.
  4. Deploy. The build output is resolved to its immutable sha256 digest, and that digest reference is deployed with the normal blue/green sequence: new container starts alongside the old one, is health-probed, and only takes traffic once healthy. A failed build never deploys anything; the running version keeps serving.

Create an app from a git repository

Add a source block to app_config. For a source-built app you do not supply image_ref:

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-services \
-H "Content-Type: application/json" \
-d '{
"name": "my-api",
"plan_name": "tier-2",
"zone": "se-sto1",
"app_config": {
"source": {
"type": "git",
"repo_url": "https://github.com/acme/api.git",
"ref": "main",
"builder": "dockerfile",
"dockerfile_path": "Dockerfile",
"build_context": ".",
"build_env": { "VITE_API_BASE": "https://api.example.com" }
},
"container_port": 8080,
"env": { "LOG_LEVEL": "info" }
}
}'

The rest of the request (plan, zone, storage, health checks, attachments) is identical to a prebuilt-image app; see Deploy an App.

app_config.source

FieldRequiredDefaultDescription
typeYesgit builds from a git repository. upload builds from a source tarball you uploaded first; see Deploy from a local directory. image (or omitting source entirely) keeps the prebuilt-image behaviour.
upload_refYes (for upload)The upload_ref returned by POST /app-source-uploads.
repo_urlYes (for git)Repository URL, https:// or SSH form (git@host:path or ssh://git@host/path). Credentials embedded in the URL (https://user:token@host/...) are rejected: they would be stored on every recorded build and returned by the builds API. Use a deploy key instead.
refNodefault branchBranch, tag, or commit to build.
deploy_keyNoPrivate SSH deploy key for a private repository. Requires an SSH repo_url; supplying it with an https:// URL is rejected, because git never offers an SSH key on an https clone. Write-only: never returned by the API, preserved when omitted on update as long as repo_url is unchanged.
builderNoautoBuild strategy. Left empty, the builder auto-detects: your Dockerfile when the repository has one, Cloud Native Buildpacks when it does not. dockerfile forces a Dockerfile build (and fails if there is none); buildpacks always builds with Cloud Native Buildpacks, ignoring any Dockerfile.
dockerfile_pathNoDockerfileDockerfile location, relative to the build context. Must be a clean relative path (no ..).
build_contextNorepository rootSubdirectory of the repository used as the build root. Useful for monorepos.
build_envNo{}Build-time variables (up to 100, each value up to 4096 bytes and 64 KiB across the whole map), passed to the build as Docker build arguments; declare a matching ARG in your Dockerfile to use one. Not injected into the running container; use app_config.env for runtime environment.
scan_policyNorecord onlyWhat a vulnerability scan does to a build. Empty records findings without blocking; block-critical and block-high fail the build on findings at or above that severity. See Vulnerability scanning.
isolation_tierNodedicatedWhere builds run. dedicated builds on your app's own VM, and is the only accepted value today. See Build isolation.

image_ref is the build output. For a source-built app you never supply image_ref: each successful build writes its digest reference (for example registry.foundrydb.com/apps/{id}@sha256:...) into the app's configuration, and the deploy path consumes it exactly as it would a customer-supplied image.

Private repositories

For a private repository, supply an SSH deploy key and an SSH-form repo_url:

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-services \
-H "Content-Type: application/json" \
-d '{
"name": "my-api",
"plan_name": "tier-2",
"zone": "se-sto1",
"app_config": {
"source": {
"type": "git",
"repo_url": "git@github.com:acme/api.git",
"ref": "main",
"deploy_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----\n"
},
"container_port": 8080
}
}'

deploy_key follows the same rules as registry_password on prebuilt-image apps: it is write-only (redacted in every API response), it travels to the builder over the encrypted internal task channel, and when you omit it on a later update the stored key is preserved, as long as repo_url is unchanged. Changing the repository clears the stored key, so an SSH key is never carried onto a repository it cannot authenticate. Use a read-only deploy key scoped to the one repository, not a personal SSH key.

The key is an SSH key, so it only works with an SSH repo_url. Supplying it alongside an https:// URL is rejected rather than stored and ignored: git would not offer it, and the clone would quietly fall back to an anonymous fetch that fails on a private repository. For the same reason, do not put a token in the URL itself (https://user:token@github.com/...): that form is rejected, because the URL is recorded on every build and returned by the builds API.

Deploy from a local directory

When the code you want to deploy is not in a repository the platform can reach, upload it as a gzipped tarball and build from that. This is the path a foundry deploy style workflow uses, and it works for a private repository you would rather not hand a deploy key for, a monorepo subtree, or a directory that is not under version control at all.

It is a two-step flow: upload the tarball, then create the app naming the upload.

1. Upload the source

tar czf src.tar.gz my-app/

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-source-uploads \
-H "Content-Type: application/octet-stream" \
--data-binary @src.tar.gz
{
"upload_ref": "703aa251a366427dbef321d4fdce15d4",
"size_bytes": 518,
"checksum_sha256": "3f380368ba8f11a0b7e8ed90f8f8272be198046e6d121ec0f0ae112cb8fd66a9",
"expires_at": "2026-07-27T23:03:44Z"
}

The request body is the tarball itself, not a multipart form or a JSON envelope. Send it with a binary content type: application/octet-stream or application/gzip. The body must really be gzipped (it is checked, not assumed) and is capped at 64 MB.

Verify checksum_sha256 against your own shasum -a 256 src.tar.gz before continuing. The builder verifies the same checksum after fetching the source, so a corrupted or truncated transfer fails with that stated cause instead of surfacing later as an unexplained tar error.

Pack the source under a single top-level directory, as tar czf src.tar.gz my-app/ does. The builder strips that one wrapping directory, so your Dockerfile ends up at the build root.

2. Create the app from the upload

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-services \
-H "Content-Type: application/json" \
-d '{
"name": "my-app",
"plan_name": "tier-2",
"zone": "se-sto1",
"app_config": {
"source": {
"type": "upload",
"upload_ref": "703aa251a366427dbef321d4fdce15d4"
},
"container_port": 8080
}
}'

Everything downstream is identical to a git build: the same builder auto-detection (your Dockerfile if the tarball has one, Cloud Native Buildpacks otherwise), the same builder, dockerfile_path, build_context and build_env fields, the same build history and logs, and the same digest-pinned image written back into image_ref.

What to know about uploads

  • Uploads expire 24 hours after they are created. They are a hand-off to a build, not storage. Create the app in the same session as the upload; an upload_ref that has expired or been swept is rejected at build dispatch with a clear error rather than producing a build that cannot fetch anything.
  • An upload is retained briefly after its build consumes it, so a build that fails and is retried does not force you to upload again.
  • Uploads are private to your account. The reference is opaque and unguessable, and ownership is checked on every use, so holding a reference is not a way to read someone else's source.
  • A rebuild rebuilds the same upload. There is no commit to move to, so triggering a rebuild rebuilds the bytes you sent. To deploy new code, upload again and update source.upload_ref. If you want a push to redeploy automatically, use a git source with push to deploy.
  • Builds from an upload record no commit. The build history shows source_type: upload with an empty commit_sha; the image digest is the provenance.

Builds API

Every build attempt is recorded, whether it succeeds or fails. All build routes hang off the app service detail path and use the same authentication and permissions as the rest of the app API.

Build history

curl -u "$USER:$PASS" https://api.foundrydb.com/app-services/{id}/builds

Returns builds newest-first (add ?limit=n to change the page size, default 50). Each build records its status (pending, building, succeeded, failed), trigger (manual, api, config), the repo_url and resolved commit_sha, the output image_ref and image_digest (empty until the build succeeds), timing fields, and an error_message on failure. A successful build that has been deployed also carries the deployment_id of the revision it fed, linking the build history to the deployment history.

Fetch a single build:

curl -u "$USER:$PASS" https://api.foundrydb.com/app-services/{id}/builds/{buildId}

Build logs

curl -u "$USER:$PASS" https://api.foundrydb.com/app-services/{id}/builds/{buildId}/logs

Returns the build's ordered step log in the same shape as deploy logs, plus the builder's raw output.

A build still in progress returns the steps recorded so far; poll until status settles on succeeded or failed.

Following a build as it runs. The response carries the raw output of Buildah or pack in output, along with a next_offset cursor. Pass that cursor back as ?offset= and each poll returns only what the builder has produced since the last one, so you can tail a running build rather than waiting for it to finish:

# first poll starts at the beginning
curl -u "$USER:$PASS" ".../builds/{buildId}/logs"
# subsequent polls resume from the previous next_offset
curl -u "$USER:$PASS" ".../builds/{buildId}/logs?offset=6143"

Offsets count characters. When the response sets truncated: true, the build produced more output than is retained and the log was cut; the build itself was unaffected. logs_trimmed: true means the output was removed by retention rather than never recorded (see below).

StepWhat it does
prepare-workspaceCreates a clean, isolated workspace for this build on the VM.
fetch-sourceClones the repository at the configured ref (using the deploy key when set).
resolve-commitResolves and records the exact commit SHA being built.
build-imageRuns the Dockerfile build with Buildah, with build_env passed as build arguments.
push-imagePushes the built image to the app's repository in the platform registry.
resolve-digestResolves the pushed image to its immutable sha256 digest, which is what gets deployed.

A failed step carries the error in message and, where available, the builder's output tail in detail. Build failures (a broken Dockerfile, a missing ref, a clone that cannot authenticate) surface here, not in the deploy logs: the deploy never starts.

Build statistics

curl -u "$USER:$PASS" https://api.foundrydb.com/app-services/{id}/build-stats
{
"total": 11, "succeeded": 11, "failed": 0, "in_flight": 0,
"duration_seconds_avg": 24.3, "duration_seconds_p50": 24.0,
"duration_seconds_p95": 33.2, "duration_seconds_min": 21.0,
"duration_seconds_max": 34.1, "last_duration_seconds": 21.0,
"success_rate_percent": 100
}

Two things worth knowing about how these are computed, because both change how you should read them:

  • Durations cover successful builds only. A failed build's duration measures how long it took to give up, so including them would let a run of fast failures look like builds getting quicker.
  • The success rate is over finished builds only. A build that is currently running is reported in in_flight and does not count against the rate, so starting a build never makes the number dip. It also means in_flight is how you tell "nothing has failed" from "nothing has finished yet".

The same figures appear above the build history in the console.

Trigger a rebuild

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-services/{id}/builds

Queues a rebuild of the current source configuration and, on success, deploys the resulting image blue/green. Returns 202 Accepted; track progress through the builds list and the app status. The request is rejected for an app that does not build from source.

This is how you pick up new commits: a POST /builds against the same ref (say main) rebuilds whatever that ref points to now.

Update build settings

curl -u "$USER:$PASS" -X PUT https://api.foundrydb.com/app-services/{id}/build-settings \
-H "Content-Type: application/json" \
-d '{
"repo_url": "https://github.com/acme/api.git",
"ref": "v2.1.0",
"builder": "dockerfile",
"dockerfile_path": "deploy/Dockerfile",
"build_context": "services/api",
"build_env": { "VITE_API_BASE": "https://api.example.com" }
}'

Updates the source configuration (everything in the source.* table except type, which is fixed at creation) and queues a rebuild with the new settings. deploy_key is preserved when omitted and repo_url is unchanged; include it only to set or rotate the key, and re-supply it when you point the app at a different repository.

When builds run

Builds are explicit. A build runs when:

  • the app is first created with a git source (initial provision),
  • you POST /app-services/{id}/builds (rebuild the current ref), or
  • you change the build settings via PUT .../build-settings or a config update.

There is no automatic build on git push yet (see the limitations below), so pushing to your repository does nothing until you trigger a rebuild.

A failed build never deploys. The app keeps serving the previously built image, the failed attempt is recorded in the build history with its step log and error, and you can fix the source and rebuild. This is the same safety property the blue/green health gate gives deploys, applied one stage earlier.

Recovering from a failed build

A failed build leaves the app in a build-failed state rather than Running. That state is a stopping point, not a dead end: read the failure from the build's step log, fix the cause, and push it through again with any of

  • POST /app-services/{id}/builds after fixing the repository, to rebuild the same settings,
  • PUT /app-services/{id}/build-settings, when the build settings themselves were wrong (a dockerfile_path that does not exist, a ref that was never pushed),
  • PATCH /app-services/{id}, to change the wider app configuration at the same time, or
  • POST /app-services/{id}/rollback, to put the last good revision back while you work on the fix.

What happens next depends on how far the app had got:

  • The app was already serving a container (a rebuild of a live app failed). The retry rebuilds and then rolls the new image into place blue/green. The old container keeps serving throughout, exactly as it did while the build was failing.
  • The app never completed its first deploy (the very first build failed). The retry resumes the original provisioning run at the build step, and the successful build goes on to the first-time deploy. Rollback is not available here, because there is no previously deployed revision to return to.

Requests are still rejected while the app is genuinely busy: provisioning, deleting, or already applying a change. Wait for it to settle, then retry.

Vulnerability scanning

Every source build scans the image it produced and records the findings by severity. You can see them per build in the console and in the builds API.

source.scan_policy decides what those findings do:

scan_policyBehaviour
omittedFindings are recorded. Nothing is blocked.
block-criticalThe build fails if the image has critical findings.
block-highThe build fails if the image has high or critical findings.

Recording without blocking is the default because it is the only behaviour that is safe to apply to an app that already deploys, and because seeing your real counts is what tells you whether a blocking policy is safe to turn on. Look at a few builds first, then pick a policy.

A blocked build fails with the counts that blocked it, and those counts are kept on the failed build, so you can see exactly what to fix. The previous version keeps serving; nothing is deployed.

A scan that does not complete blocks under either blocking policy. If the scanner cannot run, the build fails rather than publishing an image nobody looked at: a policy that lets an unscanned image through is not a policy. Under the default it does not block, and the build records that the scan failed rather than reporting the image as clean.

Findings come from the image as built, which means most of them usually come from your base image. Moving to a slimmer or more current base is normally a bigger reduction than anything else you can do.

Build isolation

Builds run on your app's own VM, the same single-tenant machine that runs your container. Your source, deploy key, and build environment never touch shared build infrastructure, and there is no cross-tenant neighbour during a build. The trade-off is that a heavy build competes with your running container for the VM's CPU and memory, so very large builds benefit from a bigger plan.

Because the build shares the machine with your serving container, a Dockerfile build's steps run under explicit bounds so that a runaway build fails instead of taking your app down with it:

  • Memory. A build may use a share of the VM's RAM, not all of it. The remainder is what keeps your container serving while the build runs beside it. A build that exceeds the cap is stopped and the build fails with that cause, rather than the kernel choosing a victim and evicting your app.
  • Processes. A build is capped at 4096 processes. This bounds a fork bomb, which in practice is far more often an accident (a parallel build with an unbounded job count) than an attack. Without it, exhausting the VM's process table stops your own container from forking too.

If a build fails on these limits, the fix is usually a bigger plan (more RAM) or a more restrained build (a bounded make -j), not a retry. Buildpacks builds are driven by the pack CLI, which does not expose the equivalent controls, so these bounds apply to Dockerfile builds.

The isolation_tier field (dedicated, the default) exists so that a future shared build pool (shared) can be offered for faster warm builds. Today dedicated is the only tier, and it is the only accepted value: asking for shared is rejected rather than quietly downgraded, so you are never told you have a sandboxed boundary you did not get. Omit the field.

Push to deploy

A signed webhook lets a git push rebuild and redeploy the app, so you do not have to trigger a build after every commit.

It is an ordinary repository webhook, not an installed app, so it works the same way on GitHub, GitLab, Gitea and Forgejo and there is nothing to install first.

1. Mint a signing secret. It is shown once and never returned again by any later read, so capture it now. Calling this again rotates the secret, which is how you revoke one that has leaked.

curl -u "$USER:$PASS" -X POST https://api.foundrydb.com/app-services/{id}/webhook-secret
{
"webhook_url": "https://api.foundrydb.com/webhooks/app-push/{id}",
"secret": "...",
"content_type": "application/json"
}

2. Add the webhook to your repository using that URL and secret, with content type application/json and the push event.

3. Arm auto-deploy. The webhook is inert until you do; a repository that is merely linked never deploys on its own.

curl -u "$USER:$PASS" -X PUT https://api.foundrydb.com/app-services/{id}/build-settings \
-H 'Content-Type: application/json' \
-d '{"repo_url":"https://github.com/acme/web.git","ref":"main","auto_deploy":true}'

What triggers a deploy

Only a push to the ref the app tracks. If ref is set, that branch or tag; if it is empty, the repository's default branch. A push to any other branch is acknowledged and ignored, so feature branches never reach production.

A deleted branch is ignored: there is nothing to build, and building the previous head would deploy code that was just removed.

Builds triggered this way are recorded with trigger push, so build history distinguishes a deploy someone asked for from one a commit set off.

Security

The webhook endpoint is unauthenticated by necessity: a git host presents no credentials. The signature is therefore the authentication. Each request must carry X-Hub-Signature-256 containing an HMAC-SHA256 of the exact request body under your secret, and anything that does not verify is rejected. The secret is a bearer credential: anyone holding it can trigger builds of your app, so treat it like a token and rotate it if it is exposed.

Build log retention

A build row is kept for as long as the app exists: it records the image digest and commit a deployment used, and it is what a rollback restores.

The logs attached to it are not kept forever. After 30 days a build's step log and raw output are cleared while the row itself, its status, commit and digest, remain. The most recent build of each app is never trimmed at any age, because its log describes the version you are running now. A build whose logs were reclaimed reports logs_trimmed: true, so it is distinguishable from one that never produced output.

Current limitations

Current limitations
  • Cloud Native Buildpacks is the only no-Dockerfile builder. Nixpacks was evaluated and deliberately not adopted: carrying a second builder means a second cache model, a second set of failure modes and a second thing to keep pinned, in exchange for a marginally different detection heuristic. If your stack is not detected, add a Dockerfile and the platform builds it.
  • Dockerfile or Cloud Native Buildpacks. A repository with a Dockerfile builds with it; a repository without one builds automatically with Cloud Native Buildpacks (Paketo). Set "builder" to dockerfile or buildpacks to force a strategy; the default auto-detects.
  • An uploaded source has no push-to-deploy. There is no repository to watch, so new code means a new upload. Use a git source if you want a push to redeploy on its own.
  • Zone. A source build runs on your app's own VM, so it is available only in zones whose app template carries the build toolchain (git, Buildah, pack, Skopeo). Today that is se-sto1 (Stockholm). Creating a source-built app in another zone is rejected at creation with the current list of supported zones, rather than failing later during the build. A prebuilt-image app can be created in any zone.

Next steps

  • Deploy an App: the full create and update reference shared by image and source apps
  • Deployments: blue/green mechanics, deployment history, rollback
  • Logs: runtime container logs for the serving app