Welcome to d2o PaaS
Deploy web applications, background workers, databases, and object storage from one platform. Start with the task you want to complete; the sidebar contains the full documentation.
Deploy your first app
Follow the Quickstart, or build an example app in Node.js, Rails, Python, Go, Rust, or static HTML. The tutorials include runnable downloads and both dashboard and CLI instructions. Install the CLI if you prefer the terminal.
Bring an existing application
Check supported frameworks, choose a runtime version, and review Designing Apps for d2o. Deploy with Git push or connect GitHub/GitLab. There are focused guides for Go, static sites, and background workers.
Connect data and configuration
Create and attach a database, store files in object storage, and manage environment variables and secrets. Learn how Organizations, Projects, and Environments keep resources organized and how access control works.
Prepare for production
Use Take an Example to Production as a bridge from the tutorials, then complete the production checklist. Review promotion and rollback, scaling, custom domains, and durability and recovery.
Diagnose a problem
Start with Troubleshooting & FAQ, then inspect deployments, logs, and metrics. Check limits and usage when an operation is refused.
Look up a command or setting
Use the CLI command reference, d2o-paas.yaml reference, Procfile reference, and verified runtime catalog. For integrations, see API Access & Automation or MCP.
Quickstart: Deploy Your First App
This guide takes you from an invitation email to a running app: sign in, create a Project and an Environment, create a Web Application, connect it to your GitHub or GitLab repository, and open it in a browser. From then on, every push to your branch deploys automatically.
You’ll need your app’s code in a GitHub or GitLab repository. The platform is invitation-only — there is no self-serve signup. If you do not have an invitation yet, ask an Organization owner or admin to send you one.
Accept your invitation and sign in
Open the email you received. If you are new to the platform, that email asks you to set a password; once that is done you land on the invitation accept page. If you already have an account, the email links straight to the accept page. Sign in and accept the invitation.
After signing in you have your own personal Organization, plus membership in any Organization you were invited to. The examples below use an Organization named acme.
Create a Project
In the dashboard, open your Organization’s projects and create a new Project. The Create Project form asks for:
- Project Name — a display name, for example “Shop”.
- URL Slug — lowercase letters, numbers, and hyphens. It is generated from the name and appears in dashboard URLs (
/orgs/acme/projects/shop). - Description — optional.
You can also create projects from the CLI with paas projects create — see the Command Reference.
Create an Environment
Inside the Project, create an Environment named production:
- Environment Name — lowercase letters, numbers, and hyphens only.
- Environment Type — Development, Staging, or Production.
- Git Branch — leave the default,
main. The branch your app deploys from is the one you pick when you link your repository below. - Automatic deployment — leave it on, so every push deploys automatically.
You can leave Protected Environment off for now.
Create the Web Application
Open the production Environment and create a Resource. Pick the Web Application type (an HTTP service with a web endpoint), then fill in:
- Name —
web. Lowercase letters, numbers, and hyphens; it is used in URLs and DNS. - Framework — Ruby on Rails, Node.js, Python, Go, Rust, or Static Files (see Supported Frameworks for the files each one needs).
- Port and Health Check Path — the port defaults to
3000; the dashboard suggests/healthfor dynamic apps and/for Static Files. Your app should listen on the port in thePORTenvironment variable, and answer the health check path with200 OK— if it doesn’t have a/healthpage, set the path to one it does answer, such as/. Otherwise the deployment waits for a healthy app that never appears. - Replicas, CPU Limit, Memory Limit — the defaults are fine to start.
Nothing runs yet: next, tell the platform where your code lives.
Connect GitHub or GitLab
This is done once per Organization, by an Organization owner or admin — if that isn’t you, ask one to do it.
- Open Organization Settings → Integrations.
- Click Connect GitHub or Connect GitLab.
- GitHub asks you to install the d2o PaaS app on your account or organization, and to choose which repositories it can see. Include your app’s repository. Only an owner of that GitHub account or organization can install it.
- GitLab asks you to authorize d2o.
- You’re returned to Integrations, and the connection shows as Active.
Using your own GitLab server? See Self-managed GitLab.
Link your repository
- Open the
webapp and go to its Settings. - In the Deploy from GitHub/GitLab section, pick the Connection, search for your Repository, and pick the Branch to deploy — usually
main. - Click Save. The section now says “Deploys from GitHub acme/shop@main” (or GitLab).
- Wait for CI is enabled by default, so a commit waits for its tests. If the repository has no checks, d2o waits 10 minutes before proceeding.
- Click Deploy latest to deploy the branch as it is now.
From now on, every push to that branch deploys the app automatically — just git push to GitHub or GitLab as you normally would.
Several apps from one repository (say a web server and a background worker)? Create them with Repository → The environment’s shared repository, and link the Environment instead of each app — see Link a repo to an Environment or app.
Watch the build and deployment
Each deployment builds your code with buildpacks for the framework you chose, then rolls it out. You can follow it in three places:
-
In the dashboard — the app’s Deployments tab lists each deployment; open one to see its stages, the live build log and, if it fails, the reason.
-
On GitHub or GitLab — the commit gets a
d2o: productioncheck that moves from Building to Rolling out to Live, or says why it didn’t deploy. It links to the deployment. -
From the CLI (optional — see Installing the CLI) — one command follows the whole deployment until the new version is live, or tells you why it isn’t:
paas watch -o acme -p shop -e production -a web
If the deployment fails, its page shows the reason — most first-deployment problems are an app that doesn’t listen on $PORT or doesn’t answer its health check path. See Troubleshooting & FAQ.
Open your app
When the deployment succeeds, open the app’s public URL shown in the dashboard. Your first app is live.
Need a runnable application? Follow a language tutorial to create a hello app, then add a database, worker, and storage using the dashboard or CLI.
Next steps
-
Designing Apps for d2o — configuration, storage, and process lifecycle
-
Best Practices for Production Apps — what to set up before real users arrive
-
Deploy from GitHub or GitLab — Wait for CI, direct pushes, Deploy latest and more
-
Pipelines, Promotion & Rollback — add staging and production stages
-
Environment Variables — configure your app
-
Installing the CLI — manage everything from your terminal
-
Deploying with Git Push — push straight to d2o instead of GitHub or GitLab
-
Deploying from CI — push from a CI pipeline with a deployment token
Deploying with Git Push
Every Web Application or Background Worker gets a source repository hosted on the platform. You push your code to it, the platform builds an image from your source with buildpacks, and the new version is deployed to the app’s Environment. This works the same whether your code is public or private. Pushing to this repository is one way in; you can also link a GitHub or GitLab branch so the platform deploys from it (see Deploy from GitHub or GitLab).
This guide assumes you already have an app. If you don’t, see Creating a Web Application first. Pushing requires deploy access to the app’s Project.
The examples use the paas CLI with org acme, project shop, environment production, and an app named web. Add -e production to the commands (the environment has no configurable default; it is only inferred from an existing paas or d2o remote), plus -o acme -p shop if you haven’t set defaults (paas orgs set-default, paas projects set-default). See Installing the CLI.
Upload an SSH key
Pushing over SSH authenticates with an SSH public key attached to your account. A key lets you push to any app repository you have deploy access to.
In the dashboard: open Account Settings, then under SSH Keys click Add Key. Give the key a name (for example laptop), paste your public key (the contents of a .pub file, e.g. ssh-ed25519 AAAA... user@host), and save. The list shows each key’s fingerprint; deleting a key immediately stops it from pushing.
With the CLI:
paas keys add
With no argument, this uploads ~/.ssh/id_ed25519.pub (falling back to id_ecdsa.pub, then id_rsa.pub). To upload a specific key:
paas keys add ~/.ssh/other_key.pub
paas keys list shows your keys with their IDs and fingerprints; paas keys remove <key-id> deletes one.
Add the paas remote
From inside your app’s local git repository, run:
paas git remote -a web
This looks up the app’s platform-hosted repository, adds it to your local repo as a remote named paas (or updates the remote if it already exists), and prints the result:
$ paas git remote -a web
Set remote paas → ssh://git@<git-host>:<port>/sources/acme/shop/production/web.git
The exact host and port are filled in for your installation. To print the URL without touching your git configuration, add --print. Running the command outside a git repository also just prints the URL.
-a matters. Bare paas git remote — no -a — sets up the Environment’s shared remote instead, named d2o (see One remote for several apps below). For a single app’s own remote, always pass -a <app>.
If an older checkout already has a d2o remote left over from before the shared remote existed — pointing at this app’s own repository, not an Environment — paas git remote refuses to touch it rather than silently repointing it to the Environment (a push there would then build nothing). This applies both to the bare command and to -a <shared-app> for an app on the shared source. Fix it first, then re-run whichever command you meant:
git remote rename d2o paas
If paas is already taken too (both remotes present in the same checkout), rename isn’t possible — remove d2o instead, since the per-app remote it pointed at is already available as paas:
git remote remove d2o
Push
git push paas main
The platform builds the main branch, so push to main. If your local branch has a different name, push it as main:
git push paas HEAD:main
The push returns within seconds, printing a deployment number, the commit, a link to follow it live, and the paas watch command to run:
remote: Deploy #42 started for abc1234 → web
remote: Follow live: https://d2o.io/orgs/acme/projects/shop/envs/production/deploys/42
remote: Or run: paas watch --deploy 42
It doesn’t wait for the build — open the link or run the printed command to follow what happens next.
When a push is refused
A push is refused, and nothing is built, in two cases:
- The Environment is promote-only. It runs versions promoted from another Environment, so the push is refused with
environment production is promote-only; push to `staging` and promote. Push to the named Environment instead and promote from there. The dashboard shows no push command for a promote-only Environment. - The app or Environment deploys from a linked GitHub or GitLab branch and direct pushes are off. The message says where to push instead:
This environment deploys from GitHub acme/shop@main. Push there instead, or allow direct pushes in the environment's settings.The dashboard hides the push command and says Direct pushes are off. See Deploy from GitHub or GitLab.
What happens next
- Build. The platform builds a runnable version from the pushed source with buildpacks, using the language/framework you chose when creating the app.
- Deploy. When the Environment has Auto Deploy enabled (the default), a successful build rolls out automatically, and only takes traffic once it passes its health check. With Auto Deploy off, the build waits until you deploy it yourself with
paas deploy -a web. See Deploys, Health Checks & Rollback for the full stage-by-stage picture, including what happens when a new version never becomes healthy.
Without --deploy, paas watch follows the newest deploy for the app:
paas watch -a web
Or check on it yourself:
paas releases # recent deployments for the environment
paas status -a web # the app's sync, health and instances
paas logs -a web --tail
While the image is still building, the log stream tells you so instead of streaming; once the new instances are running, logs flow normally. In the dashboard, the app’s Deployments view shows the same history and status.
HTTPS remote alternative
If you can’t use SSH (for example, from a machine with no SSH key set up), push over HTTPS with a URL that embeds an access token scoped to this app’s repository. The token expires after 30 days, and until then it grants push access, so treat the URL like a password.
In the dashboard: on the app’s Deployments tab, the Deploy via Git Push panel has a Show push command button. It generates ready-to-copy commands:
git remote add paas https://x-access-token:<token>@<git-host>/sources/acme/shop/production/web.git
git push paas HEAD:main
Use Regenerate token to mint a fresh URL for your next push. Don’t share the URL or commit it anywhere — it carries write access to the app’s source repository.
With the CLI:
paas git remote -a web --https
This configures the paas remote over HTTPS instead of SSH and stores a freshly minted push token with git’s credential helper, so the token never appears in .git/config or git remote -v. It needs a credential helper (your OS keychain, or git config --global credential.helper store) and stops with an error when none is configured. You can run it again at any time for a fresh token. When a push fails with fatal: Authentication failed after the token’s 30 days are up, run the same command again.
For CI pipelines, create a deploy token for the app’s remote (paas deploy-tokens create ci -a web), or for the Environment’s remote when your apps use the shared repository — see Deploying from CI.
One remote for several apps
Apps in one Environment can share one repository: the Environment’s own, at
sources/<org>/<project>/<environment>.git. One push then rebuilds every app
that uses it, and apps whose code is the same — same directory, same
framework — share a single build, so the image is built once and deployed to
all of them.
Create each app with --shared-source, and give each app its process with
--process when one build produces several (for example web and worker
from a Procfile, or two binaries of one Cargo project):
paas apps create admin --shared-source --framework rust --port 3000 --process admin -e production
paas apps create bot --worker --shared-source --framework rust --process bot -e production
--process is only needed when the app’s name doesn’t already match a line
in the Procfile — a newly created app named worker with a worker:
Procfile line picks it up automatically, with nothing to set. Name matching
only applies to apps created from now on, though: an app that already existed
keeps running its current process regardless of its name, until you clear its
Process setting (paas apps update <app> --process ""). See
Procfile for the full order the platform tries. A
file’s own d2o-paas.yaml can set process: too, for apps that keep their
settings in the repository — see
d2o-paas.yaml Reference.
If an app’s code lives in a directory of the repository, add
--source-dir <DIR>. Then add the Environment’s remote — without -a — and
push once:
paas git remote -e production
git push d2o main
paas git remote without -a adds the Environment’s remote as d2o; an
app’s own remote keeps the name paas. In the dashboard, the Environment’s
Deployments tab has a Git remote panel with the same commands.
Inside the shared repository the CLI works out the app from your working
directory: the app whose directory contains it, or the only app that builds
from the repository root. When several apps build from the root, pass -a.
paas watch without -a follows every app of the push until each runs the
new build.
To move an existing app to the shared repository:
paas apps update admin --shared-source -e production
The app keeps running its current version until the first shared build
succeeds. Its own repository is kept until you delete the app, but it no longer
accepts pushes; switch back with paas apps update admin --own-source.
Switching back rebuilds immediately from the app’s own repository’s last pushed commit — whatever that was before you moved it to the shared repository, which can be old or stale. The own repository starts accepting pushes again the moment you switch back, so push the code you actually want right away rather than assuming the version that comes up is current.
Next steps
- How Builds & Deployments Work — what happens between push and running app
- Deployments, Health Checks & Rollback — stages, health checks, and rollback
- Logs — snapshots and live streaming
- Account, SSH Keys & API Tokens — managing your keys and credentials
Installing the CLI
The paas command-line tool lets you manage Organizations, Projects, Environments, and Resources from your terminal — everything from creating an app to streaming its logs. This page gets it installed and logged in.
Most of what the CLI does is also available in the dashboard, so the CLI is optional — though a few things, such as printing a bucket’s S3 credentials, are currently CLI-only. It’s also the fastest way to work once you deploy regularly.
Prerequisites
- A platform account. The platform is invitation-only, so you should already have accepted an invitation and be able to sign in to the dashboard. If not, see Inviting People.
- Nothing else. The CLI is a single prebuilt binary with no runtime dependencies — you do not need a Rust toolchain unless you choose to build it yourself.
Install
On macOS and Linux, the install script picks the right build for your operating system and CPU, verifies its SHA-256 checksum, and installs the paas binary into ~/.local/bin. If minisign is installed, the script also verifies the release signature and refuses to install on a mismatch; without it, the script says it skipped that check and installs on the checksum alone.
curl -fsSL https://dl.d2o.io/install.sh | sh
If ~/.local/bin is not on your PATH, the script says so and prints the line to add to your shell profile. Three environment variables change what it does:
| Variable | Effect |
|---|---|
PAAS_INSTALL_DIR | Install somewhere other than ~/.local/bin. |
PAAS_VERSION | Install a specific version (0.12.0 or v0.12.0) rather than the latest. |
PAAS_INSTALL_BASE | Download from a mirror instead of https://dl.d2o.io. |
For example, to pin a version and install into ~/bin:
curl -fsSL https://dl.d2o.io/install.sh | PAAS_VERSION=0.12.0 PAAS_INSTALL_DIR="$HOME/bin" sh
Verify it:
paas --version
The language tutorials and paas apps attach, paas apps attachments, and paas apps detach require 0.9.0 or later. paas deploy-tokens, paas connections and paas links need version 0.8.0 or later; paas deploys and paas manifest check need 0.7.0; paas attach and paas detach need 0.4.0. Run paas --version to check; to upgrade, run the install script again.
Install manually
Releases are published under https://dl.d2o.io/paas/v<VERSION>/. Check the current version:
curl -fsS https://dl.d2o.io/paas/latest.json
The examples below read that version, so they install the latest release. To pin a release, set VERSION (or $v on Windows) yourself.
| Platform | Archive |
|---|---|
| Linux x86_64 | paas-linux-amd64.tar.gz |
| Linux arm64 | paas-linux-arm64.tar.gz |
| macOS Intel | paas-darwin-amd64.tar.gz |
| macOS Apple silicon | paas-darwin-arm64.tar.gz |
| Windows x86_64 | paas-windows-amd64.zip |
| Windows arm64 | paas-windows-arm64.zip |
macOS: select the archive for your CPU (arm64 below; use amd64 for Intel).
VERSION=$(curl -fsS https://dl.d2o.io/paas/latest.json | sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p')
BASE="https://dl.d2o.io/paas/v$VERSION"
ARCHIVE=paas-darwin-arm64.tar.gz
curl -fsSLO "$BASE/$ARCHIVE" && \
curl -fsSLO "$BASE/SHA256SUMS" && \
shasum -a 256 --ignore-missing -c SHA256SUMS && \
tar xzf "$ARCHIVE" && \
sudo install -d /usr/local/bin && \
sudo install -m 0755 paas /usr/local/bin/paas
Linux: select amd64 or arm64 for your CPU.
VERSION=$(curl -fsS https://dl.d2o.io/paas/latest.json | sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p')
BASE="https://dl.d2o.io/paas/v$VERSION"
ARCHIVE=paas-linux-amd64.tar.gz
curl -fsSLO "$BASE/$ARCHIVE" && \
curl -fsSLO "$BASE/SHA256SUMS" && \
sha256sum --ignore-missing -c SHA256SUMS && \
tar xzf "$ARCHIVE" && \
sudo install -m 0755 paas /usr/local/bin/paas
Stop if download or verification fails; do not install an archive with a checksum mismatch. Expect an OK line for the archive you selected. --ignore-missing skips the other platform archives listed in the shared checksum file.
Each archive is flat and holds the binary, a completions/ directory, a README, and — on Linux and macOS — the paas.1 man page.
Optionally, verify the release signature too (the install script does this for you when minisign is installed). SHA256SUMS.minisig is a minisign signature over SHA256SUMS, which in turn covers the archives, so checking it covers everything. Your platform operator publishes the public key:
curl -fsSLO $BASE/SHA256SUMS.minisig
minisign -Vm SHA256SUMS -P 'RWQHV7FbUhxhZzDFI+69HMeEtGMmL1XWAPl3rLKpPs70Bt+2YyC6zgsZ'
Opening https://dl.d2o.io/ in a browser returns an AccessDenied document rather than a file listing. That is expected: the download host serves the objects above, not an index.
Windows
There is no install script for Windows. Download the .zip for your architecture, compare its hash against the matching line in SHA256SUMS, unpack it, and add the folder holding paas.exe to your PATH:
$v = (Invoke-RestMethod https://dl.d2o.io/paas/latest.json).version
$base = "https://dl.d2o.io/paas/v$v"
$dir = "$env:LOCALAPPDATA\Programs\paas"
Invoke-WebRequest "$base/paas-windows-amd64.zip" -OutFile paas-windows-amd64.zip
Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS
$want = (Select-String 'paas-windows-amd64.zip' SHA256SUMS).Line.Split()[0]
$got = (Get-FileHash paas-windows-amd64.zip -Algorithm SHA256).Hash
if ($want -ne $got) { throw "checksum mismatch - do not install this file" }
New-Item -ItemType Directory -Force $dir
Expand-Archive paas-windows-amd64.zip -DestinationPath $dir -Force
$old = [Environment]::GetEnvironmentVariable('Path', 'User')
$new = (@($old -split ';') + $dir | Where-Object { $_ }) -join ';'
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
Get-FileHash prints the hash in uppercase and SHA256SUMS records it in lowercase — that is fine, because PowerShell compares strings case-insensitively. Run the PATH block once; it appends, so a second run adds a duplicate entry.
The PATH change only reaches new processes, so open a fresh terminal before running paas --version.
Build from source
If your platform has no published binary, or you want to run an unreleased change, build the CLI from a checkout with a Rust toolchain:
cargo install --path crates/paas
Shell completions
Every archive ships completions in completions/, and the CLI can also print them for bash, elvish, fish, powershell, or zsh. Send the output to your shell’s completion directory:
paas completions zsh
Log in
Logging in happens through your browser, so you never type your password into the terminal:
paas login --host <your-platform-url>
<your-platform-url> is the full dashboard origin, including https:// (hosted d2o: https://dashboard.d2o.io). You need --host the first time; after that the CLI remembers your current host, and a plain paas login targets it.
Step by step:
- The CLI starts a small listener on your own machine (
127.0.0.1) and opens your browser to the platform. It also prints the address, so if no browser opens you can visit it in a browser on the same machine. - The platform’s sign-in page appears — sign in with your account. (You are asked to sign in each time you authorize a CLI login, even if you already have an active dashboard session.)
- A page titled Approve d2o CLI sign-in shows the account you’re signed in as, that a code will be sent to
127.0.0.1:<port>on this machine — that is, back to the CLI — and the name of the API token it will create. Click Approve sign-in. If you didn’t runpaas login, close the tab. Approve within 10 minutes of runningpaas login; after that the page says “CLI login expired or missing — runpaas loginagain”. - The browser hands a short-lived, single-use code back to the CLI, which exchanges it for an API token and stores it. The tab then reads Signed in to d2o PaaS (“You can close this tab and return to your terminal”) — you are logged in. If it reads Sign-in didn’t complete instead, your terminal says why.
Each login creates a named API token so you can tell your devices apart later. The default name is cli <hostname>; pick your own with --name:
paas login --host <your-platform-url> --name "work-laptop"
Logging in again replaces the stored token and attempts to revoke the old one. If revocation fails, the old token remains listed under paas auth tokens list; revoke it there.
Remote terminals and CI
The browser must reach the CLI listener on the machine running paas login. Opening the printed URL on your laptop while the CLI runs on a remote server sends the callback to the laptop instead. Use SSH port forwarding for the callback port, or provision an account API token through your secret-management workflow and supply PAAS_API_URL and PAAS_API_TOKEN in the remote environment. See API Access & Automation; a Git deployment token cannot log in to the API.
Verify
paas whoami
This prints the logged-in user for your current host. If instead you see “Not logged in”, run paas login again.
Where the token is stored
The CLI keeps its state in your platform’s configuration directory:
- Linux:
~/.config/paas/config.toml - macOS:
~/Library/Application Support/paas/config.toml - Windows:
%APPDATA%\paas\config.toml(that is,C:\Users\<you>\AppData\Roaming\paas\config.toml)
This file holds your API token for each host you have logged in to, plus your current host and any default Organization or Project you set (there is no stored default Environment — supply it with -e or run inside a repo with a paas or d2o git remote). The token is stored in plain text. On Linux and macOS the CLI restricts the file to your user; on Windows it inherits whatever your user profile directory allows. Either way, treat the file like a password and do not commit or share it.
For scripts and CI, you can bypass the config file entirely: set PAAS_API_URL to the platform address and PAAS_API_TOKEN to a token. An environment-supplied token is never written to disk.
You can review every token issued to your account with paas auth tokens list and revoke one by ID with paas auth tokens revoke <uuid> — useful if a laptop goes missing. See Account, SSH Keys & API Tokens.
Log out
paas logout
This revokes the current host’s token on the server and removes it from your local config. If your token comes from the PAAS_API_TOKEN environment variable, paas logout refuses — unset the variable instead.
Next steps
- CLI Overview & Authentication — context flags, defaults, and
--jsonoutput - Command Reference — every command and flag
- Quickstart: Deploy Your First App — from zero to a running app
- Deploying with Git Push — push your code straight to the platform
Best Practices for Production Apps
This guide is for anyone taking an app from “it works” to “people depend on it”. It explains the setup we recommend, and the habits that keep an app up while you deploy, scale, change its database and recover from mistakes. Each section says what to do, why it matters, and where to read more.
If you’re in a hurry, jump to the checklist at the end.
The recommended setup
GitHub / GitLab repo (main)
│ every push
▼
development builds every push · Wait for CI on · linked to the repo
│ promote (the same version, never rebuilt)
▼
staging promote-only
│ promote
▼
production promote-only · Protected
In words:
- Your code lives on GitHub or GitLab, and one Environment (here
development) is linked to yourmainbranch. Every push is built and deployed there automatically. - Wait for CI is on (CI is your automated tests, such as GitHub Actions or GitLab CI), so d2o waits for the commit’s checks. Configure a test job on the linked branch: if a commit has no checks, d2o deploys after 10 minutes. See Wait for CI.
- Staging and production never build anything. You promote the exact version that already ran in development, so what you tested is byte-for-byte what you ship.
- Production is Protected, so it can’t be deleted by accident and promoting into it asks you to confirm.
The rest of this guide explains each piece, and how to build your app so that deployments don’t interrupt anyone.
1. Deploy from GitHub or GitLab
Linking a repository is the recommended way to get code onto d2o:
- Your repo is the source of truth. What’s running is always a commit you can see on GitHub or GitLab, reviewed through your normal pull or merge requests.
- Tests gate deployments. With Wait for CI on, d2o waits for the commit’s checks (GitHub Actions, GitLab CI, or any other CI that reports a status) and refuses to deploy a commit whose checks failed.
- You see the result on the commit. Each push gets a
d2o: <environment>check showing Building, Rolling out and then Live — or why it didn’t deploy — with a link to the deployment. - Keep deployments tied to the linked repository. Leave Allow direct pushes off to refuse pushes straight to d2o’s git remote. Configure branch protection and required reviews on GitHub or GitLab to enforce your review policy.
To set it up:
- An organization Owner or Admin connects GitHub or GitLab under the organization’s Settings → Integrations.
- Link your build Environment (or an app — see the next section) to the branch you deploy, usually
main. - Turn on Wait for CI if your repository runs tests.
If a push ever doesn’t start a deployment, Deploy latest deploys the branch’s current commit on demand.
Can’t use GitHub or GitLab? A CI job can push with a deployment token instead. It’s scoped to one Environment or app and belongs to the organization, not to a person.
See Deploy from GitHub or GitLab for every option.
2. One repository for the Environment, or one per app?
Every Environment can deploy in two ways, and you choose per app:
| Environment repository (shared) | App’s own repository | |
|---|---|---|
| What it is | One repository for the whole Environment. Each app builds from it, optionally from its own directory. | Each app has its own repository. |
| A push… | rebuilds every app that uses it, all at the same commit. Apps in the same directory share one build. | builds and deploys just that app. |
| Release command | Runs once per build, not once per app. | Runs for that app. |
| Best for | One codebase that runs several processes — a web server plus a background worker, or several binaries from one project. | Independent services with their own repos or their own release schedule. |
| Linking | Link the Environment. | Link the app. |
Rule of thumb: if your apps share code and should always run the same version, use the Environment repository. If they’re separate projects that change at different times, give each its own.
With the Environment repository, each app picks which process to run — web, worker, and so on — from your Procfile or from d2o-paas.yaml. See One remote for several apps.
3. Build once, then promote
Set up a pipeline of Environments in your project:
- One build Environment (for example
development). It builds every push and is the one you link to GitHub or GitLab. - Promote-only Environments after it (
staging,production). Switch them to promote-only in the Environment’s Settings → General → Release source before you create apps in them; each app you then create there starts from the same-named app upstream.
Promoting copies the exact build that ran upstream — it never rebuilds — so a version that worked in staging is the same bytes in production. The pipeline board on the project page shows which version is in each stage and promotes with one click, or from the CLI:
paas promote web --from staging --to production -o acme -p shop
Things to know:
- Data and settings aren’t promoted. Each Environment has its own databases, buckets, secrets and environment variables. Set them up in every stage.
- Your app’s release command runs in each Environment you promote into, against that Environment’s database (see migrations).
- Link only the build Environment to GitHub or GitLab. Promote-only Environments can’t be linked, because they never build.
- Turn on Protected for production. Promoting into a protected Environment asks you to type its name, and it can’t be deleted.
See Pipelines, Promotion & Rollback.
4. Build your app the way the platform expects
Follow Designing Apps for d2o: keep durable state outside instances, read runtime configuration from the environment, validate required settings, bind to 0.0.0.0:$PORT, and log to stdout/stderr. Temporary scratch files are fine; they must not be the only copy of data you need to keep.
Version process selection, port, health checks, and release commands in d2o-paas.yaml. The tutorial-to-production guide identifies additional work needed before exposing the example apps to real users.
5. Zero-downtime deployments
When you deploy, d2o replaces your app’s instances one at a time:
- It starts one new instance next to the old ones.
- It waits until the new instance passes its health check.
- Only then does it stop one old instance — and repeats until every instance runs the new version.
Old instances keep serving traffic the whole time. If the new version never becomes healthy, the old ones are never stopped and the platform rolls back for you. A few habits make this work.
Give every web app a health check
Set an HTTP path your app actually serves; give workers an HTTP or command check where practical. See health-check defaults and rollout waiting periods. A missing probe cannot establish that an app is ready to serve, and a singleton still stops before its replacement starts.
Keep the health check fast and shallow
Return a quick, unauthenticated success response once the process has started. Avoid cascading restarts when a shared dependency is briefly unavailable. The health-check guide explains ongoing checks and failure behavior.
Shut down gracefully
Handle SIGTERM, drain in-flight work, and exit within the 30-second window. Workers must safely retry interrupted jobs. Follow the shutdown and retry guidance.
A stuck deployment fails after five minutes
A deployment fails if five minutes pass without another new instance becoming healthy; a slow deployment that keeps making progress is never cut short. A new instance that keeps crashing — three restarts — fails the deployment right away, with the app’s last log lines as the reason. Follow a deployment in the dashboard, or with:
paas watch
See Deployments, Health Checks & Rollback.
6. High availability
Run at least two instances
An app with one instance has one point of failure. When it runs two or more:
- They’re spread across different servers, so if one server fails, the other instances keep serving.
- Platform maintenance moves one instance at a time, so the app stays up.
With a single instance, your app is unavailable while that instance moves — for a few minutes if its server fails. For anything your users depend on, run at least two:
paas scale 2 -o acme -p shop -e production -a web
Autoscale with a floor of two
Autoscaling adds instances when CPU use is high and removes them when it drops. For production, set the minimum to at least 2, so the app stays spread across servers even when traffic is quiet.
The Target CPU % is measured against each instance’s baseline of 0.1 CPU core (or its CPU limit, if that’s lower), not its CPU limit — so the default target of 70 adds instances once they average about 0.07 cores each.
Use singleton only when an app must never run twice
A singleton app runs at most one instance, and every deployment, restart or configuration change stops the old instance before starting the new one. That means a short outage every time — from a few seconds to a minute, depending on how fast your app stops and starts — and several minutes if its server fails.
Use it only when two copies running at once would cause a problem — a chat bot that holds one live session, or a scheduler that must not fire twice. Everything else should run normally.
Give instances enough memory
An instance that uses more memory than its Memory Limit is stopped and restarted (exit code 137). Watch the memory chart on the app’s Metrics and raise the limit before you get close to it. CPU works differently: an instance that uses too much is slowed down, not stopped.
Protect your data
Choose PostgreSQL with high availability when production data needs automatic failover. Attach services instead of copying credentials. Review the durability and recovery matrix for backup coverage, Redis/MongoDB limitations, object-storage tiers, and what deletion removes. Practise recovery; replication does not undo accidental deletion.
7. Change your database safely
Run migrations as a release command
Configure a release command for versioned migrations. It runs with the new code and target Environment’s configuration before rollout, while old code continues serving. A shared build runs it once for the build, not independently for every app. Follow the execution and retry rules in that guide; keep long backfills in background jobs.
Old and new code run at the same time
This is the most important rule for migrations. During a deployment, old and new instances serve traffic side by side for a while. And after a rollback, the old code runs against the database your new migration already changed — a rollback never undoes database changes.
So every migration must keep working with both the version before it and the version after it.
Do:
- Add before you remove. Add a new column or table in one deployment, and remove the old one in a later deployment, once no running code uses it.
- Make new columns optional (nullable, or with a default), so old code that doesn’t know about them can still insert rows.
- Widen before you narrow — loosen a constraint before the code relies on the looser rule; tighten it only after all code obeys it.
Don’t:
- Rename or drop a column in one step. Old instances still running would break immediately.
- Edit a migration that has already run. Write a new one instead.
Example: renaming name to full_name, safely. Remember that the release command runs before the new code starts, so each step must still work with the code from the step before:
- Deployment 1 — add a nullable
full_namecolumn. The code writes both columns and readsname. - Deployment 2 — copy the old values into
full_name(as a background job, if the table is large). The code still writes both, but now readsfull_name. - Deployment 3 — make
namenullable. The code stops using it. - Deployment 4 — drop
name.
Make migrations safe to run twice
A release command can occasionally run more than once — for example if its server is restarted mid-run, or when you roll back by hand, which runs the older version’s release command again. Use your framework’s migration tool, which records what has already run, rather than raw SQL scripts.
For PostgreSQL, each deployment whose release command ran shows a data restore point — the time to ask support to restore to if a migration damages data. See Database migrations and rollbacks.
8. Rollbacks
Automatic rollback: keep it on
If a new version doesn’t become healthy, d2o puts the previous version back on its own. This is on by default. Keep it on.
It protects you during the deployment itself. It doesn’t help with:
- A build or release command that fails. Nothing changed yet, so the old version simply keeps running.
- A version that breaks after it went live. Roll back by hand (below).
- Configuration changes. Changing an environment variable or attaching a database restarts your app with the new settings, but it isn’t a tracked deployment: there’s no deployment number and no automatic rollback. A health check still keeps traffic away from instances that never become ready. Try configuration changes in staging first.
Rolling back by hand
How you roll back depends on the Environment:
- In a promote-only Environment (staging, production), click Rollback… in the app’s header and pick an earlier release, or run
paas rollback web --to <deployment-id>(the ids are inpaas deploys list --json). It switches straight back to a build that already ran there, so it’s fast — nothing is rebuilt. This is one more reason to promote into production rather than build there. - In a build Environment, fix forward: push a fix, or
git revertthe bad commit and push. The branch decides what runs, so a revert is a rollback. (If the newest deployment failed, its deployment page also offers Roll back.)
What a rollback restores
| Restored | Not restored |
|---|---|
| Your app’s code | Environment variables and secrets |
Settings that come from your repo (d2o-paas.yaml, Procfile) | Instance count, CPU and memory |
| Attached databases and buckets | |
| Your database’s contents |
Because the database stays as it is, a rollback is only safe if your migrations followed the rules above.
9. Configuration and secrets
Set plain configuration at the appropriate scope using the precedence rules. Keep each stage’s settings separate. Use secrets for credentials and attachments for managed services.
Variable changes and secret rotation roll out automatically; the secret lifecycle table explains the delete-and-recreate exception. Test configuration changes in staging because they do not have numbered-deployment automatic rollback.
10. Watch your app
- Follow every deployment.
paas watchends with exit code0only when the new version is live, so you can use it as a step in CI with an account API token. See CI verification. - Read your logs in the app’s Logs tab or with
paas logs --tail— they include every instance. See Logs. - Check metrics after a deployment and when traffic changes: CPU, memory and, for web apps, requests and response times. See Metrics.
- Deployment problems are emailed to the organization owner: failed builds, failed or rolled-back deployments.
Production checklist
Before you send real users to an app, check that:
- The code deploys from a linked GitHub or GitLab repository, with Wait for CI on and direct pushes off.
- There’s a build Environment, and staging and production are promote-only. Production is Protected.
- The app listens on
$PORTon0.0.0.0, logs to stdout/stderr, and keeps durable state outside instances (local scratch files are temporary). - It checks its required settings at startup and exits with a clear message if one is missing.
- Web apps have a fast, shallow health check path; workers have a health check command.
- The app handles
SIGTERMand finishes in-flight work within 30 seconds. - Production runs at least two instances (or autoscales with a minimum of two), and isn’t a singleton unless it must be.
- Memory limits leave headroom above normal use.
- Production data lives in PostgreSQL with High availability, attached rather than copied.
- Migrations run as the release command, are backward compatible, and are safe to run twice.
- Automatic rollback is on, and you know how to roll back by hand in each Environment.
- Credentials are in secrets, and each stage has its own settings and databases.
Next steps
- Deploy from GitHub or GitLab — connect your repository
- Pipelines, Promotion & Rollback — set up development, staging and production
- Deployments, Health Checks & Rollback — what happens during a deployment, in detail
- Scaling & Autoscaling — instances, CPU, memory and singleton apps
- d2o-paas.yaml Reference — keep your app’s settings in your repo
Build an example app
Start with a small HTTP app, then give it persistent notes, a background export worker, and private object storage. Choose one language; each path is self-contained.
| Tutorial | What you build | Builder |
|---|---|---|
| Node.js and Express | Express web app and Node worker | heroku-nodejs |
| Ruby on Rails | Rails API app and Ruby worker | heroku-ruby |
| Python and Flask | Flask with Gunicorn and a Python worker | heroku-python |
| Go | Standard-library HTTP server and Go worker | heroku-go |
| Rust and Axum | Axum web app and Rust worker | paketo-rust |
| Static HTML | NGINX site with a public bucket asset | paketo-web-servers |
Every server-side path follows the same sequence:
- Create and deploy a hello app.
- Store notes in PostgreSQL.
- Queue exports in Redis and process them in a separate worker.
- Store exports in a private S3-compatible bucket.
- Optionally replace PostgreSQL with MariaDB or MongoDB.
PostgreSQL, MariaDB, and MongoDB are alternative primary stores; Redis is the queue. You do not need all three primary databases for an app. The static path uses browser-safe public assets; it has no server process that can use database credentials.
Each chapter provides dashboard and CLI paths. Choose one path for each step; do not create the same resource twice. Both paths push source with Git. The prerequisites describe access, tooling, and availability checks. Local services let you try the same source on your computer.
These are learning apps without user accounts. Anyone with the app URL can read and create notes and request downloads, even though the backing bucket is private. Use sample text only. Add authentication, authorization, rate limits, and export retention before adapting the code to real users. Queued jobs and export status depend on Redis; notes remain in the primary database if Redis data is lost, and exports can be regenerated.
When you finish, follow Take an Example to Production. It connects the learning checkpoints to app design, migrations, availability, and recovery guidance.
Before you start
1. Check access and tools
You need an invited d2o account, an Organization, permission to create and deploy apps, and permission to manage databases and object storage in your tutorial Project. See Access Control. Creating resources consumes your plan’s quota.
Install Git and your chosen language runtime. Install the paas CLI
if following the CLI path. A POSIX shell (bash/zsh, or WSL on Windows) is used below.
Docker Compose is only needed for optional local service tests. The AWS CLI is only
needed if you follow the optional local S3 tests; hosted uploads use paas buckets put.
2. Check that your installation has the tutorial features
Use CLI 0.9.0 or later for app-scoped attachment commands. Check paas --version and update with the installation instructions if necessary. The dashboard path uses Process type on Create Resource to select web and worker processes.
All six tutorial frameworks are available on hosted d2o. Check the framework requirements and runtime catalog for the language version you use. On a self-hosted installation, your operator can confirm whether its builders and storage services are enabled.
Object storage availability can vary by Environment type; these examples use Development Environments.
3. Create one Project
Dashboard: open your Organization → Create Project, name it Examples, and
use the slug examples. You will create a separate Development Environment for each
language, so the example resource names never collide.
CLI: replace your-org below with your Organization slug, then run:
TUTORIAL_ORG=your-org
paas login --host https://dashboard.d2o.io
paas projects create examples -o "$TUTORIAL_ORG" --name Examples
paas keys add
For a self-hosted installation, substitute your dashboard origin in paas login; see API addresses.
If examples already exists and is suitable for disposable tutorial data, reuse it.
Keep TUTORIAL_ORG set in each terminal running CLI commands. Each chapter spells out
Project and Environment flags; it does not change your saved defaults.
4. Prepare Git authentication
Dashboard: Account Settings → SSH Keys → Add Key; paste your public key. The Environment’s Git remote panel supplies the actual remote URL and push commands.
CLI: paas keys add uploads your existing public key. If you do not have an SSH key,
follow Deploying with Git Push first. The tutorial uses
the Environment’s shared repository from its first commit so the web and worker can
share one build later. There is no external GitHub repository to configure.
5. Use the source checkpoints
Each language provides two ZIPs: hello and notes. The downloads include source,
lockfiles, and a local compose.yaml; they exclude installed dependencies and credentials.
The hello chapter shows the small app’s files. The notes ZIP is the complete next checkpoint:
copy it over hello, then enable its database, queue, and storage integrations one chapter at a time.
The source appendix explains the application code; no code generator is required.
Each app listens on 0.0.0.0:$PORT, with port 3000 configured on PaaS, and exposes
GET /health. Health intentionally checks the HTTP process only, allowing you to open
a console and initialize the database schema before the notes routes are usable.
Run the notes examples locally
Local testing is optional; the deployment walkthrough uses managed PaaS resources.
These commands use only the disposable services in the example’s compose.yaml.
The supplied passwords are local demo values, not PaaS credentials.
1. Start the services
Install Docker with Compose. From an unpacked notes download, run:
docker compose up -d --wait
cp .env.example .env
set -a
. ./.env
set +a
From a repository checkout, use docker compose -f ../../compose.yaml up -d --wait
inside examples/<language>/notes instead. The file starts PostgreSQL, MariaDB,
MongoDB, Redis, and a SeaweedFS S3 service.
All published ports bind to 127.0.0.1. They use unusual port numbers to avoid
colliding with your existing development databases.
2. Choose a primary database
The default .env.example selects PostgreSQL. For another engine, change these two
values in your local .env, then load it again in both web and worker terminals:
DATABASE_ENGINE | DATABASE_URL (local demo only) |
|---|---|
postgresql | postgresql://tutorial:tutorial@127.0.0.1:18432/notes |
mariadb | mysql://tutorial:tutorial@127.0.0.1:18306/notes |
mongodb | mongodb://tutorial:tutorial@127.0.0.1:18017/notes?authSource=admin |
REDIS_URL=redis://:tutorial@127.0.0.1:18379/0 is shared by the web and worker.
Run one language at a time against this local queue; otherwise one language’s worker
can pick up another’s export request. Use distinct Redis database numbers for
independent simultaneous examples.
3. Install and start the chosen app
Follow the download’s README to install dependencies. Initialize the schema once, then start the web process and worker in separate terminals with the same environment:
| Language | Initialize schema | Web | Worker |
|---|---|---|---|
| Node.js | node app.mjs migrate | npm start | node app.mjs worker |
| Rails | bundle exec ruby bin/rails runner 'NotesStore.migrate' | bundle exec puma -b tcp://0.0.0.0:3000 | bundle exec ruby worker.rb |
| Python | python app.py migrate | gunicorn --bind 0.0.0.0:3000 app:app | python app.py worker |
| Go | go run . migrate | go run . web | go run . worker |
| Rust | cargo run --locked --bin migrate | cargo run --locked --bin web | cargo run --locked --bin worker |
Activate .venv in each Python terminal. For Rails, set a local SECRET_KEY_BASE
using ruby -rsecurerandom -e 'puts SecureRandom.hex(64)'; use RAILS_ENV=development locally.
Open http://localhost:3000, save a note, and export it. Without the optional S3
variables, the worker stores the generated JSON in Redis.
4. Enable local object storage
Uncomment all five S3/AWS variables in .env and reload it. With the AWS CLI installed,
create the local bucket once:
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 mb "s3://$S3_BUCKET"
Restart both processes with those variables and request a new export. List its file:
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 ls "s3://$S3_BUCKET/exports/"
These settings point at the local S3-compatible service. On PaaS, bucket attachments supply the actual endpoint, region, name, and credentials instead.
5. Stop and reset
Stop web/worker processes with Ctrl+C. docker compose stop retains local service state;
docker compose down -v removes the local tutorial containers and data. From the repo
checkout, include -f ../../compose.yaml again. These commands do not touch your PaaS resources.
Return to your language tutorial for hosted deployment, or continue with production preparation.
Node.js and Express: notes and background exports
You will use Node.js 22 and build a web app named notes-web plus a worker
named notes-worker in the examples Project’s nodejs Environment.
Express handles HTTP routes. The pg, mysql2, and MongoDB drivers implement the three persistence options; redis and the AWS S3 SDK handle jobs and files.
Complete Before you start, then follow these chapters in order:
- Create and deploy hello
- Persist notes in PostgreSQL
- Add a Redis export worker
- Save exports in object storage
- Try MariaDB or MongoDB instead
- Prepare for production, or troubleshoot and clean up
Downloads: hello source · complete notes source. The source walkthrough is included directly from those runnable files.
1. Create and deploy a Node.js and Express hello app
You will see a hello response at / and ok at /health before adding any services.
1. Download and run locally
Download the hello ZIP into a working directory, then open a terminal in that directory:
unzip nodejs-hello.zip
cd nodejs-hello
npm ci
npm start
Keep it running. In another terminal, curl -fsS http://localhost:3000/health should print ok.
Open http://localhost:3000/ and confirm the hello message. Stop the server with Ctrl+C.
The app source is small enough to inspect in full:
package.json
{
"name": "d2o-hello",
"private": true,
"type": "module",
"engines": {
"node": "22.x"
},
"scripts": {
"start": "node server.mjs"
}
}
server.mjs
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end(req.url === '/health' ? 'ok\n' : 'Hello from Node.js on d2o!\n');
});
server.listen(Number(process.env.PORT || 3000), '0.0.0.0');
process.on('SIGTERM', () => server.close());
Procfile
web: npm start
Keep the supplied lockfiles in Git. The buildpack uses these manifests and its runtime version declaration to reproduce the app’s dependencies.
2. Create the Environment
Dashboard: open Project Examples → create Environment nodejs, type Development,
branch main, release source Build, Automatic deployment on.
CLI:
paas envs create nodejs -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
3. Create the web app
Dashboard: inside nodejs choose Add Resource → Web Application.
Name it notes-web, choose Framework Node.js,
Repository The environment’s shared repository, Directory blank (repository root),
and Process type web. Use Port 3000, Health Check Path /health, and one replica.
Click Create Resource.
CLI:
paas apps create notes-web --framework nodejs --shared-source --start-command web --port 3000 --health-check-path /health -o "$TUTORIAL_ORG" -p examples -e nodejs
4. Commit and push
Initialize a repository in the unpacked hello directory:
git init -b main
git add .
git commit -m "Add hello application"
Dashboard path: open the Environment’s Git remote panel, copy its shared-repository remote command into this directory, then run its push command. This is the Environment remote, not a per-app remote. The dashboard supplies the correct host and authentication URL.
CLI path: omit -a when setting the shared remote:
paas git remote -o "$TUTORIAL_ORG" -p examples -e nodejs
git push d2o main
paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
5. Verify the deployed app
Dashboard: open notes-web → Deployments, wait for the successful build and running
instance, then open its public URL. Append /health; it should display ok.
CLI: paas ps and paas logs with -o "$TUTORIAL_ORG" -p examples -e nodejs and -a notes-web show the running process.
Use the public URL displayed in the dashboard, or read the url field from
paas apps info notes-web --json with the same Organization, Project, and Environment flags.
APP_URL=https://your-app-host
curl -fsS "$APP_URL/health"
curl -fsS "$APP_URL/"
Set APP_URL to your actual URL, with no trailing slash. If the build fails, see
troubleshooting before continuing.
Next: Persist notes in PostgreSQL.
2. Persist notes in PostgreSQL
Replace hello with the notes checkpoint, attach PostgreSQL, and create the schema once. The Redis and S3 code is present but is not used until you request an export.
1. Replace the source checkpoint
Download the complete notes ZIP beside your hello directory.
From inside nodejs-hello, run:
cd ..
unzip nodejs-notes.zip
cp -R nodejs-notes/. nodejs-hello/
cd nodejs-hello
rm server.mjs
npm ci
This retains the hello directory’s .git history and shared remote. The rm command
removes only the superseded hello source file in this tutorial directory. The source appendix
shows the database implementation. Notes use UUID IDs, validated text,
and parameterized SQL or the MongoDB driver; no file on the app instance stores notes.
For an optional local run, follow Local services and run
node app.mjs migrate before npm start.
2. Create and attach PostgreSQL
Dashboard: in nodejs add a Database named notes-db, engine PostgreSQL, with
1 GB storage. Wait until running. Open notes-web → Config → Attached Resources,
select notes-db, leave the variable name blank (DATABASE_URL), and click Attach.
In the same Config tab add plain variable DATABASE_ENGINE=postgresql.
CLI:
paas dbs create notes-db --type postgresql --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e nodejs
paas dbs info notes-db -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-web notes-db -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=postgresql -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
Wait for database provisioning before trying to initialize the schema. The attachment
injects DATABASE_URL; never copy credentials into the source or .env in Git.
Check attachment names with paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs.
3. Deploy the notes app
git add .
git commit -m "Add persistent notes and export processes"
git push d2o main
Dashboard: wait for notes-web to be running in its Deployments/Overview view.
CLI: run paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs. /health succeeds even before the schema exists;
/notes may return 503 until the next step.
4. Initialize the schema
Dashboard: notes-web → Console → select a running instance → Connect. Run:
/cnb/process/migrate
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs -- /cnb/process/migrate
The migrate process is declared in the Procfile. Calling /cnb/process/migrate
loads the buildpack runtime environment, including the language executable and dependencies.
Expect Schema ready. The command creates the notes table if missing, so it is safe to
repeat for this example. paas run does not relay the remote exit code: inspect the output
and verify /notes, rather than assuming a zero CLI exit means the schema succeeded.
5. Check persistence
Open the app URL, enter a sample note, and choose Save note. Refresh; the note remains. You can also test from your terminal:
curl -fsS -H "Content-Type: application/json" -d '{"body":"My first persistent note"}' "$APP_URL/notes"
curl -fsS "$APP_URL/notes"
Restart notes-web from its dashboard action or run paas restart -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs.
Wait for it to be running and fetch /notes again; the note must still be present.
An empty body returns HTTP 400. Leave Export notes for the next chapter.
For production, replace manual schema setup with versioned release-command migrations.
Next: Add a Redis export worker.
3. Add a Redis export worker
The web app queues a job and immediately returns its ID. A separate process reads the notes and generates JSON. This chapter stores that JSON in Redis; the next moves it to S3.
1. Create Redis
Dashboard: in nodejs add Database notes-queue, engine Redis, 1 GB storage;
wait until running. In notes-web → Config, attach it with variable name REDIS_URL.
Do not use the default DATABASE_URL, which already points at PostgreSQL.
CLI:
paas dbs create notes-queue --type redis --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-web notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e nodejs
2. Create the worker from the same code
Dashboard: add a Background Worker named notes-worker, Framework nodejs
(the same selection as the web app), Repository The environment’s shared repository,
Directory blank, Process type worker, one replica. A worker has no public HTTP endpoint.
Open its Config tab, set DATABASE_ENGINE=postgresql, and attach both notes-db as
DATABASE_URL and notes-queue as REDIS_URL.
CLI:
paas apps create notes-worker --worker --framework nodejs --shared-source --process worker -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=postgresql -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-worker notes-db -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-worker notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e nodejs
The worker must have its own attachments; the web app’s configuration is not inherited. The process value is a named buildpack process, not a shell command. See the worker source for the job loop.
3. Push and check the worker
Trigger a shared build after both apps have their configuration:
git commit --allow-empty -m "Deploy web and export worker"
git push d2o main
Dashboard: wait for both apps to run, then open notes-worker → Logs and look for
Worker ready. CLI: paas watch -o "$TUTORIAL_ORG" -p examples -e nodejs follows both apps; then use
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs. The web app must still answer /health.
4. Export and recover a queued job
In the browser, click Export notes, wait for the download link, and open the JSON.
It must contain your saved note. Logs show Export ready: <id>.
To check queueing while the worker is unavailable, open its dashboard Overview and use − in the Replicas row to reach 0,
then confirm the stop, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs. Request an export; it stays queued.
Use + in the same row to restore replicas to 1 (or paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs); the same job should complete.
If the UI polling times out after 90 seconds, query /exports/<id> and download from
/exports/<id>/download once ready, or submit another export.
The worker uses Redis Streams with a consumer group. It acknowledges only after saving the result. A replacement worker claims unacknowledged jobs idle for 30 seconds; writes use the same export ID so a retried job overwrites the same result. Failures remain queued for retry and appear in worker logs. This is at-least-once delivery, not exactly-once delivery. Do not use this loop unchanged for side effects such as charging a payment.
Redis itself is not backed up by the platform. For essential jobs, retain enough information in the primary database to recreate or reconcile them. See durability and recovery and production preparation.
Next: Save exports in object storage.
4. Save exports in object storage
Keep notes in the database and move newly generated JSON exports into a private bucket. The web app streams downloads from S3; browser code never receives bucket credentials.
1. Create the bucket
Dashboard: in nodejs add Object Storage, name notes-exports, tier Capacity,
quota 1 GB. Leave it private and wait until provisioned. Capacity is suitable here
because exports can be recreated from notes. Use High availability for irreplaceable files.
CLI:
paas buckets create notes-exports --tier capacity --quota-gb 1 -o "$TUTORIAL_ORG" -p examples -e nodejs
paas buckets info -a notes-exports -o "$TUTORIAL_ORG" -p examples -e nodejs
Wait for available: true; keep public: false.
2. Attach the bucket to both apps
Dashboard: open each app’s Config tab, choose bucket notes-exports in the attachment
card, leave the credential prefix blank, and click Attach.
CLI:
paas apps attach notes-web notes-exports -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-worker notes-exports -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
The primary attachment injects S3_ENDPOINT, S3_BUCKET, S3_REGION,
AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY. The storage implementation
passes the endpoint and region explicitly and uses path-style S3 requests.
Do not add these credentials to frontend code, Git, or ordinary config variables.
Wait for both apps to restart with the new attachment before testing.
3. Generate a new export
Open the app and request a new export. Exports made before the bucket attachment still live in Redis, so their old download links no longer work while S3 mode is enabled. Download the new JSON and confirm your note appears.
Dashboard: open notes-exports → Files → exports/. The file named <job-id>.json
should be present. CLI:
paas buckets ls exports/ -a notes-exports -o "$TUTORIAL_ORG" -p examples -e nodejs
A direct anonymous object request should return 403; downloading through the app succeeds
because its server uses the attachment credentials. The bucket stays private, but the example
app itself has no user authentication. Do not enter confidential data.
4. Verify durability and diagnose failures
Restart notes-worker, submit another export, and check that it completes. Restart notes-web
and download the completed export again. No result depends on either app’s local filesystem.
If a job stays queued, check worker logs, both apps’ attachment lists, and bucket readiness.
After fixing a failed S3 write, the unacknowledged job retries after its 30-second idle interval.
Export files and Redis status records have no expiration in this teaching example. Delete unneeded files in the bucket’s Files tab, or clean up the entire isolated Environment when done.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MariaDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes, Redis queue, worker, and object-storage integration. The app selects the same SQL notes table, through its MariaDB driver. This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in nodejs create Database notes-mariadb, engine MariaDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mariadb --type mariadb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e nodejs
paas dbs info notes-mariadb -o "$TUTORIAL_ORG" -p examples -e nodejs
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mariadb using DATABASE_URL,
then set DATABASE_ENGINE=mariadb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-web notes-mariadb -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-worker notes-mariadb -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=mariadb -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=mariadb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs. Create a new note identifying MariaDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MongoDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes,
Redis queue, worker, and object-storage integration. The app selects a notes collection with a unique id index, through its MongoDB driver.
This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in nodejs create Database notes-mongodb, engine MongoDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mongodb --type mongodb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e nodejs
paas dbs info notes-mongodb -o "$TUTORIAL_ORG" -p examples -e nodejs
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mongodb using DATABASE_URL,
then set DATABASE_ENGINE=mongodb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-web notes-mongodb -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attach notes-worker notes-mongodb -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=mongodb -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas config set DATABASE_ENGINE=mongodb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs. Create a new note identifying MongoDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Node.js and Express source walkthrough
The runnable source below is included from the same files packaged in the notes download. Lockfiles, configuration, and the shared browser UI are included in the download.
Database
A notes request reaches this adapter after input validation. DATABASE_ENGINE chooses
PostgreSQL, MariaDB, or MongoDB; DATABASE_URL comes from the app’s attachment. SQL uses
parameters rather than interpolating note text, and MongoDB uses driver operations.
The initialization process creates a table or unique index. Records live in the database,
so replacing a web instance leaves them available to its replacement.
const engine = process.env.DATABASE_ENGINE || 'postgresql';
let db;
async function database() {
if (db) return db;
if (!process.env.DATABASE_URL) throw new Error('Attach a database first');
if (engine === 'postgresql') db = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
else if (engine === 'mariadb') db = mysql.createPool(process.env.DATABASE_URL);
else if (engine === 'mongodb') {
const client = new MongoClient(process.env.DATABASE_URL);
await client.connect();
db = client.db().collection('notes');
} else throw new Error('Unsupported DATABASE_ENGINE');
return db;
}
async function migrate() {
const db = await database();
if (engine === 'mongodb') await db.createIndex({ id: 1 }, { unique: true });
else await db.query('CREATE TABLE IF NOT EXISTS notes (id VARCHAR(36) PRIMARY KEY, body TEXT NOT NULL)');
console.log('Schema ready');
}
async function notes() {
const db = await database();
if (engine === 'mongodb') return db.find({}, { projection: { _id: 0 } }).sort({ id: 1 }).toArray();
const result = await db.query('SELECT id, body FROM notes ORDER BY id');
return engine === 'postgresql' ? result.rows : result[0];
}
async function addNote(body) {
const db = await database(), note = { id: randomUUID(), body };
if (engine === 'mongodb') await db.insertOne({ ...note });
else await db.query(engine === 'postgresql'
? 'INSERT INTO notes (id, body) VALUES ($1, $2)'
: 'INSERT INTO notes (id, body) VALUES (?, ?)', [note.id, body]);
return note;
}
Worker
Creating an export enqueues an ID and returns immediately. This loop reads the notes, writes an export under that ID, records its status, and acknowledges the stream entry. It also reclaims jobs abandoned before acknowledgement. Retrying writes the same export key, so interruption does not create an additional export file; the snapshot can reflect newer notes if they changed before the retry. See worker recovery.
async function worker() {
const redis = await queue(), consumer = randomUUID();
try { await redis.xGroupCreate('exports', 'workers', '0', { MKSTREAM: true }); }
catch (error) { if (!error.message.includes('BUSYGROUP')) throw error; }
let stopping = false;
process.on('SIGTERM', () => { stopping = true; });
process.on('SIGINT', () => { stopping = true; });
console.log('Worker ready');
while (!stopping) {
try {
const claimed = await redis.xAutoClaim('exports', 'workers', consumer, 30000, '0-0', { COUNT: 1 });
const messages = claimed.messages.length ? claimed.messages
: (await redis.xReadGroup('workers', consumer, [{ key: 'exports', id: '>' }], { COUNT: 1, BLOCK: 5000 }))?.[0].messages || [];
for (const message of messages) {
const id = message.message.id;
if (!/^[0-9a-f-]{36}$/.test(id || '')) {
await redis.xAck('exports', 'workers', message.id);
continue;
}
if (await redis.get(`export:${id}`) !== 'ready') {
await saveExport(redis, id, JSON.stringify(await notes(), null, 2));
await redis.set(`export:${id}`, 'ready');
}
await redis.xAck('exports', 'workers', message.id);
await redis.xDel('exports', message.id);
console.log(`Export ready: ${id}`);
}
} catch {
console.error('Export failed; unacknowledged jobs retry after 30 seconds. Check attachments and schema.');
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
process.exit(0);
}
Object storage
Without S3 configuration, exports are stored in Redis for the worker checkpoint. With
an attached bucket, the worker writes JSON to exports/<id>.json and the web process
reads that object for downloads. Both processes need their own bucket attachment.
The client uses the supplied endpoint and region with path-style requests. Credentials
stay on the server; a private bucket does not add authentication to the example’s public download route.
let s3;
function storage() {
s3 ||= new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION, forcePathStyle: true });
return s3;
}
async function saveExport(redis, id, data) {
if (process.env.S3_BUCKET) {
await storage().send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: `exports/${id}.json`, Body: data, ContentType: 'application/json' }));
} else await redis.set(`export:${id}:data`, data);
}
async function loadExport(redis, id) {
if (!process.env.S3_BUCKET) return redis.get(`export:${id}:data`);
const result = await storage().send(new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: `exports/${id}.json` }));
return result.Body.transformToString();
}
Complete application
The complete source and startup commands are in the notes ZIP linked above. The HTTP
layer connects the three components: POST /notes validates and saves a record,
GET /notes lists records, POST /exports queues work, and the export status and
download routes expose the result. Follow the language’s tutorial sequence
to exercise each part, then review production preparation.
Troubleshoot and clean up Node.js and Express
Diagnose a failed checkpoint
Commit package-lock.json and use npm ci locally. Keep Procfile and package.json in the source root. The worker mode is node app.mjs worker; npm start runs the web mode.
| Symptom | Check |
|---|---|
| No build starts | The Environment uses release source Build, the pushed branch is main, and both apps use the shared repository root. Review framework availability. |
| Build fails detection | Check the manifest and lockfiles in the committed source, not just local files. See the hello chapter’s file list. |
| Health check fails | Port is 3000, binding is 0.0.0.0, path is /health, and the web app selects web. |
| Notes returns 503 | Primary attachment is DATABASE_URL, engine matches its type, database is running, and the schema command printed Schema ready. |
| Export stays queued | Worker selects worker, has its own database and REDIS_URL attachments, and has one running replica. A recovered pending job waits up to 30 seconds. |
| Download fails after adding S3 | Attach the bucket to both apps with no prefix, wait for their restart, and create a new export. Old Redis-only exports have no S3 object. |
| Forbidden operation | Confirm your project capabilities for deploying, databases, storage, or secrets; ask the project administrator to grant the needed access. |
Dashboard: inspect the app’s Deployments, Logs, and Config tabs. The Process field is also editable under Settings; clearing it restores the buildpack’s default process.
CLI:
paas ps -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas logs -a notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e nodejs
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e nodejs
Do not print connection strings or credentials into logs. The example’s error responses identify the failing checkpoint without exposing a password.
Before deleting the example, review Take an Example to Production if you plan to adapt it. For platform-wide symptoms, see Troubleshooting & FAQ.
Clean up
Keep anything you want to retain by downloading exports first. Deleting this tutorial Environment permanently removes its apps and data resources; it is not a way to pause them.
Dashboard: open Project Examples → Environment nodejs → Settings → Delete Environment,
then confirm its name. Make sure this Environment contains only the tutorial resources.
CLI: review its resources, then confirm deletion:
paas apps list -o "$TUTORIAL_ORG" -p examples -e nodejs
paas dbs list -o "$TUTORIAL_ORG" -p examples -e nodejs
paas envs delete nodejs -o "$TUTORIAL_ORG" -p examples --confirm nodejs
This removes the web/worker, primary and optional databases, Redis, and tutorial bucket
created in this Environment. If you used local services, run docker compose down -v
from the example folder to discard that separate local dataset.
Ruby on Rails: notes and background exports
You will use Ruby 3.4 and Bundler and build a web app named notes-web plus a worker
named notes-worker in the examples Project’s rails Environment.
This is a small Rails API application using Rails routing and controllers. Active Record handles PostgreSQL and MariaDB (the Trilogy adapter); the Mongo Ruby driver handles MongoDB. It deliberately has no asset pipeline or database-backed job framework: the worker chapter shows the Redis queue directly.
Complete Before you start, then follow these chapters in order:
- Create and deploy hello
- Persist notes in PostgreSQL
- Add a Redis export worker
- Save exports in object storage
- Try MariaDB or MongoDB instead
- Prepare for production, or troubleshoot and clean up
Downloads: hello source · complete notes source. The source walkthrough is included directly from those runnable files.
1. Create and deploy a Ruby on Rails hello app
You will see a hello response at / and ok at /health before adding any services.
1. Download and run locally
Download the hello ZIP into a working directory, then open a terminal in that directory:
unzip rails-hello.zip
cd rails-hello
bundle install
bundle exec puma -b tcp://0.0.0.0:3000
Keep it running. In another terminal, curl -fsS http://localhost:3000/health should print ok.
Open http://localhost:3000/ and confirm the hello message. Stop the server with Ctrl+C.
The app source is small enough to inspect in full:
Gemfile
source "https://rubygems.org"
ruby "~> 3.4.0"
gem "rails", "~> 8.0.0"
gem "puma", "~> 6.6"
Procfile
web: bundle exec puma -b tcp://0.0.0.0:$PORT
config.ru
require_relative "config/environment"
run Rails.application
Rakefile
require_relative "config/application"
Rails.application.load_tasks
bin/rails
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require "bundler/setup"
require "rails/commands"
config/application.rb
require "bundler/setup"
require "rails"
require "action_controller/railtie"
module HelloTutorial
class Application < Rails::Application
config.load_defaults 8.0
config.api_only = true
config.eager_load = ENV["RAILS_ENV"] == "production"
config.hosts << ENV["PAAS_PUBLIC_HOST"] if ENV["PAAS_PUBLIC_HOST"]
config.logger = ActiveSupport::Logger.new($stdout)
end
end
config/environment.rb
require_relative "application"
Rails.application.initialize!
config/routes.rb
Rails.application.routes.draw do
root "hello#index"
get "/health", to: "hello#health"
end
app/controllers/hello_controller.rb
class HelloController < ActionController::API
def index
render plain: "Hello from Rails on d2o!\n"
end
def health
render plain: "ok\n"
end
end
Keep the supplied lockfiles in Git. The buildpack uses these manifests and its runtime version declaration to reproduce the app’s dependencies.
2. Create the Environment
Dashboard: open Project Examples → create Environment rails, type Development,
branch main, release source Build, Automatic deployment on.
CLI:
paas envs create rails -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
3. Create the web app
Dashboard: inside rails choose Add Resource → Web Application.
Name it notes-web, choose Framework Ruby on Rails,
Repository The environment’s shared repository, Directory blank (repository root),
and Process type web. Use Port 3000, Health Check Path /health, and one replica.
Click Create Resource.
CLI:
paas apps create notes-web --framework rails --shared-source --start-command web --port 3000 --health-check-path /health -o "$TUTORIAL_ORG" -p examples -e rails
4. Set the Rails application secret
Generate a value locally with ruby -rsecurerandom -e 'puts SecureRandom.hex(64)'.
In the dashboard, open the rails Environment’s Secrets page and add
RAILS_SECRET_KEY_BASE with that value. Secret creation is currently dashboard-only,
including when you follow the CLI path. Do not put the value in Git or a plain config variable.
Dashboard: on notes-web → Config, add SECRET_KEY_BASE, choose Link to secret,
and select RAILS_SECRET_KEY_BASE.
CLI: after creating the secret in the dashboard:
paas config set --secret-ref RAILS_SECRET_KEY_BASE SECRET_KEY_BASE -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
The platform supplies the value at runtime. Local development can use a locally generated
SECRET_KEY_BASE; the source never contains a deployment secret.
5. Commit and push
Initialize a repository in the unpacked hello directory:
git init -b main
git add .
git commit -m "Add hello application"
Dashboard path: open the Environment’s Git remote panel, copy its shared-repository remote command into this directory, then run its push command. This is the Environment remote, not a per-app remote. The dashboard supplies the correct host and authentication URL.
CLI path: omit -a when setting the shared remote:
paas git remote -o "$TUTORIAL_ORG" -p examples -e rails
git push d2o main
paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
6. Verify the deployed app
Dashboard: open notes-web → Deployments, wait for the successful build and running
instance, then open its public URL. Append /health; it should display ok.
CLI: paas ps and paas logs with -o "$TUTORIAL_ORG" -p examples -e rails and -a notes-web show the running process.
Use the public URL displayed in the dashboard, or read the url field from
paas apps info notes-web --json with the same Organization, Project, and Environment flags.
APP_URL=https://your-app-host
curl -fsS "$APP_URL/health"
curl -fsS "$APP_URL/"
Set APP_URL to your actual URL, with no trailing slash. If the build fails, see
troubleshooting before continuing.
Next: Persist notes in PostgreSQL.
2. Persist notes in PostgreSQL
Replace hello with the notes checkpoint, attach PostgreSQL, and create the schema once. The Redis and S3 code is present but is not used until you request an export.
1. Replace the source checkpoint
Download the complete notes ZIP beside your hello directory.
From inside rails-hello, run:
cd ..
unzip rails-notes.zip
cp -R rails-notes/. rails-hello/
cd rails-hello
rm app/controllers/hello_controller.rb
bundle install
This retains the hello directory’s .git history and shared remote. The rm command
removes only the superseded hello source file in this tutorial directory. The source appendix
shows the database implementation. Notes use UUID IDs, validated text,
and parameterized SQL or the MongoDB driver; no file on the app instance stores notes.
For an optional local run, follow Local services and run
bundle exec ruby bin/rails runner 'NotesStore.migrate' before bundle exec puma -b tcp://0.0.0.0:3000.
2. Create and attach PostgreSQL
Dashboard: in rails add a Database named notes-db, engine PostgreSQL, with
1 GB storage. Wait until running. Open notes-web → Config → Attached Resources,
select notes-db, leave the variable name blank (DATABASE_URL), and click Attach.
In the same Config tab add plain variable DATABASE_ENGINE=postgresql.
CLI:
paas dbs create notes-db --type postgresql --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rails
paas dbs info notes-db -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-web notes-db -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=postgresql -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
Wait for database provisioning before trying to initialize the schema. The attachment
injects DATABASE_URL; never copy credentials into the source or .env in Git.
Check attachment names with paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rails.
3. Deploy the notes app
git add .
git commit -m "Add persistent notes and export processes"
git push d2o main
Dashboard: wait for notes-web to be running in its Deployments/Overview view.
CLI: run paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails. /health succeeds even before the schema exists;
/notes may return 503 until the next step.
4. Initialize the schema
Dashboard: notes-web → Console → select a running instance → Connect. Run:
/cnb/process/migrate
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails -- /cnb/process/migrate
The migrate process is declared in the Procfile. Calling /cnb/process/migrate
loads the buildpack runtime environment, including the language executable and dependencies.
Expect Schema ready. The command creates the notes table if missing, so it is safe to
repeat for this example. paas run does not relay the remote exit code: inspect the output
and verify /notes, rather than assuming a zero CLI exit means the schema succeeded.
5. Check persistence
Open the app URL, enter a sample note, and choose Save note. Refresh; the note remains. You can also test from your terminal:
curl -fsS -H "Content-Type: application/json" -d '{"body":"My first persistent note"}' "$APP_URL/notes"
curl -fsS "$APP_URL/notes"
Restart notes-web from its dashboard action or run paas restart -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails.
Wait for it to be running and fetch /notes again; the note must still be present.
An empty body returns HTTP 400. Leave Export notes for the next chapter.
For production, replace manual schema setup with versioned release-command migrations.
Next: Add a Redis export worker.
3. Add a Redis export worker
The web app queues a job and immediately returns its ID. A separate process reads the notes and generates JSON. This chapter stores that JSON in Redis; the next moves it to S3.
1. Create Redis
Dashboard: in rails add Database notes-queue, engine Redis, 1 GB storage;
wait until running. In notes-web → Config, attach it with variable name REDIS_URL.
Do not use the default DATABASE_URL, which already points at PostgreSQL.
CLI:
paas dbs create notes-queue --type redis --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-web notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e rails
2. Create the worker from the same code
Dashboard: add a Background Worker named notes-worker, Framework rails
(the same selection as the web app), Repository The environment’s shared repository,
Directory blank, Process type worker, one replica. A worker has no public HTTP endpoint.
Open its Config tab, set DATABASE_ENGINE=postgresql, and attach both notes-db as
DATABASE_URL and notes-queue as REDIS_URL.
CLI:
paas apps create notes-worker --worker --framework rails --shared-source --process worker -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=postgresql -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-worker notes-db -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-worker notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e rails
Also link the worker’s SECRET_KEY_BASE variable to RAILS_SECRET_KEY_BASE
in Config, or run paas config set --secret-ref RAILS_SECRET_KEY_BASE SECRET_KEY_BASE -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails.
The worker must have its own attachments; the web app’s configuration is not inherited. The process value is a named buildpack process, not a shell command. See the worker source for the job loop.
3. Push and check the worker
Trigger a shared build after both apps have their configuration:
git commit --allow-empty -m "Deploy web and export worker"
git push d2o main
Dashboard: wait for both apps to run, then open notes-worker → Logs and look for
Worker ready. CLI: paas watch -o "$TUTORIAL_ORG" -p examples -e rails follows both apps; then use
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails. The web app must still answer /health.
4. Export and recover a queued job
In the browser, click Export notes, wait for the download link, and open the JSON.
It must contain your saved note. Logs show Export ready: <id>.
To check queueing while the worker is unavailable, open its dashboard Overview and use − in the Replicas row to reach 0,
then confirm the stop, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails. Request an export; it stays queued.
Use + in the same row to restore replicas to 1 (or paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails); the same job should complete.
If the UI polling times out after 90 seconds, query /exports/<id> and download from
/exports/<id>/download once ready, or submit another export.
The worker uses Redis Streams with a consumer group. It acknowledges only after saving the result. A replacement worker claims unacknowledged jobs idle for 30 seconds; writes use the same export ID so a retried job overwrites the same result. Failures remain queued for retry and appear in worker logs. This is at-least-once delivery, not exactly-once delivery. Do not use this loop unchanged for side effects such as charging a payment.
Redis itself is not backed up by the platform. For essential jobs, retain enough information in the primary database to recreate or reconcile them. See durability and recovery and production preparation.
Next: Save exports in object storage.
4. Save exports in object storage
Keep notes in the database and move newly generated JSON exports into a private bucket. The web app streams downloads from S3; browser code never receives bucket credentials.
1. Create the bucket
Dashboard: in rails add Object Storage, name notes-exports, tier Capacity,
quota 1 GB. Leave it private and wait until provisioned. Capacity is suitable here
because exports can be recreated from notes. Use High availability for irreplaceable files.
CLI:
paas buckets create notes-exports --tier capacity --quota-gb 1 -o "$TUTORIAL_ORG" -p examples -e rails
paas buckets info -a notes-exports -o "$TUTORIAL_ORG" -p examples -e rails
Wait for available: true; keep public: false.
2. Attach the bucket to both apps
Dashboard: open each app’s Config tab, choose bucket notes-exports in the attachment
card, leave the credential prefix blank, and click Attach.
CLI:
paas apps attach notes-web notes-exports -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-worker notes-exports -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
The primary attachment injects S3_ENDPOINT, S3_BUCKET, S3_REGION,
AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY. The storage implementation
passes the endpoint and region explicitly and uses path-style S3 requests.
Do not add these credentials to frontend code, Git, or ordinary config variables.
Wait for both apps to restart with the new attachment before testing.
3. Generate a new export
Open the app and request a new export. Exports made before the bucket attachment still live in Redis, so their old download links no longer work while S3 mode is enabled. Download the new JSON and confirm your note appears.
Dashboard: open notes-exports → Files → exports/. The file named <job-id>.json
should be present. CLI:
paas buckets ls exports/ -a notes-exports -o "$TUTORIAL_ORG" -p examples -e rails
A direct anonymous object request should return 403; downloading through the app succeeds
because its server uses the attachment credentials. The bucket stays private, but the example
app itself has no user authentication. Do not enter confidential data.
4. Verify durability and diagnose failures
Restart notes-worker, submit another export, and check that it completes. Restart notes-web
and download the completed export again. No result depends on either app’s local filesystem.
If a job stays queued, check worker logs, both apps’ attachment lists, and bucket readiness.
After fixing a failed S3 write, the unacknowledged job retries after its 30-second idle interval.
Export files and Redis status records have no expiration in this teaching example. Delete unneeded files in the bucket’s Files tab, or clean up the entire isolated Environment when done.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MariaDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes, Redis queue, worker, and object-storage integration. The app selects the same SQL notes table, through its MariaDB driver. This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in rails create Database notes-mariadb, engine MariaDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mariadb --type mariadb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rails
paas dbs info notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rails
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mariadb using DATABASE_URL,
then set DATABASE_ENGINE=mariadb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rails
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-web notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-worker notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=mariadb -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=mariadb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails. Create a new note identifying MariaDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MongoDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes,
Redis queue, worker, and object-storage integration. The app selects a notes collection with a unique id index, through its MongoDB driver.
This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in rails create Database notes-mongodb, engine MongoDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mongodb --type mongodb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rails
paas dbs info notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rails
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mongodb using DATABASE_URL,
then set DATABASE_ENGINE=mongodb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rails
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-web notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attach notes-worker notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=mongodb -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas config set DATABASE_ENGINE=mongodb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails. Create a new note identifying MongoDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Ruby on Rails source walkthrough
The runnable source below is included from the same files packaged in the notes download. Lockfiles, configuration, and the shared browser UI are included in the download.
Database
A notes request reaches this adapter after input validation. DATABASE_ENGINE chooses
PostgreSQL, MariaDB, or MongoDB; DATABASE_URL comes from the app’s attachment. SQL uses
parameters rather than interpolating note text, and MongoDB uses driver operations.
The initialization process creates a table or unique index. Records live in the database,
so replacing a web instance leaves them available to its replacement.
def database
DB_MUTEX.synchronize do
return @database if @database
url = ENV.fetch("DATABASE_URL")
@database = case ENGINE
when "mongodb" then Mongo::Client.new(url, server_selection_timeout: 5)[:notes]
when "postgresql", "mariadb"
# Active Record uses the Trilogy adapter for MariaDB.
url = url.sub(/\Amysql:/, "trilogy:") if ENGINE == "mariadb"
ActiveRecord::Base.establish_connection(url)
else raise "Unsupported DATABASE_ENGINE"
end
end
end
def migrate
db = database
if ENGINE == "mongodb"
db.indexes.create_one({ id: 1 }, unique: true)
else
ActiveRecord::Base.connection_pool.with_connection do |c|
c.execute("CREATE TABLE IF NOT EXISTS notes (id VARCHAR(36) PRIMARY KEY, body TEXT NOT NULL)")
end
end
puts "Schema ready"
end
def notes
db = database
if ENGINE == "mongodb"
db.find.projection(_id: 0).sort(id: 1).to_a
else
Note.order(:id).pluck(:id, :body).map { |id, body| { id: id, body: body } }
end
end
def add_note(body)
db = database
note = { id: SecureRandom.uuid, body: body }
ENGINE == "mongodb" ? db.insert_one(note.dup) : Note.create!(note)
note
end
Worker
Creating an export enqueues an ID and returns immediately. This loop reads the notes, writes an export under that ID, records its status, and acknowledges the stream entry. It also reclaims jobs abandoned before acknowledgement. Retrying writes the same export key, so interruption does not create an additional export file; the snapshot can reflect newer notes if they changed before the retry. See worker recovery.
stopping = false
Signal.trap("TERM") { stopping = true }
Signal.trap("INT") { stopping = true }
consumer = SecureRandom.uuid
NotesStore.queue do |q|
begin
q.call("XGROUP", "CREATE", "exports", "workers", "0", "MKSTREAM")
rescue Redis::CommandError => error
raise unless error.message.include?("BUSYGROUP")
end
puts "Worker ready"
until stopping
begin
messages = q.call("XAUTOCLAIM", "exports", "workers", consumer, "30000", "0-0", "COUNT", "1")[1]
if messages.empty?
result = q.call("XREADGROUP", "GROUP", "workers", consumer, "COUNT", "1", "BLOCK", "5000", "STREAMS", "exports", ">")
messages = result ? result[0][1] : []
end
messages.each do |message_id, values|
id = Hash[*values]["id"]
unless id&.match?(/\A[0-9a-f-]{36}\z/)
q.call("XACK", "exports", "workers", message_id)
next
end
unless q.get("export:#{id}") == "ready"
NotesStore.save_export(q, id, JSON.pretty_generate(NotesStore.notes))
q.set("export:#{id}", "ready")
end
q.call("XACK", "exports", "workers", message_id)
q.call("XDEL", "exports", message_id)
puts "Export ready: #{id}"
end
rescue StandardError
warn "Export failed; unacknowledged jobs retry after 30 seconds. Check attachments and schema."
sleep 1
end
end
end
Object storage
Without S3 configuration, exports are stored in Redis for the worker checkpoint. With
an attached bucket, the worker writes JSON to exports/<id>.json and the web process
reads that object for downloads. Both processes need their own bucket attachment.
The client uses the supplied endpoint and region with path-style requests. Credentials
stay on the server; a private bucket does not add authentication to the example’s public download route.
def storage
Aws::S3::Client.new(endpoint: ENV.fetch("S3_ENDPOINT"), region: ENV.fetch("S3_REGION"), force_path_style: true)
end
def save_export(q, id, data)
if ENV["S3_BUCKET"]
storage.put_object(bucket: ENV.fetch("S3_BUCKET"), key: "exports/#{id}.json", body: data, content_type: "application/json")
else
q.set("export:#{id}:data", data)
end
end
def load_export(q, id)
if ENV["S3_BUCKET"]
storage.get_object(bucket: ENV.fetch("S3_BUCKET"), key: "exports/#{id}.json").body.read
else
q.get("export:#{id}:data")
end
end
Routes and controller
Rails.application.routes.draw do
root "notes#index"
get "/health", to: "notes#health"
get "/notes", to: "notes#list"
post "/notes", to: "notes#create"
post "/exports", to: "notes#export"
get "/exports/:id", to: "notes#status"
get "/exports/:id/download", to: "notes#download"
end
class NotesController < ActionController::API
rescue_from StandardError do
render json: { error: "Check attachments, schema, and service availability" }, status: 503
end
def index
render html: File.read(Rails.root.join("public/index.html")).html_safe
end
def health
render plain: "ok\n"
end
def list
render json: NotesStore.notes
end
def create
body = params[:body]
unless body.is_a?(String) && !body.strip.empty? && body.length <= 2000
return render json: { error: "Enter a note of 1–2000 characters" }, status: 400
end
render json: NotesStore.add_note(body), status: 201
end
def export
id = SecureRandom.uuid
NotesStore.queue do |q|
q.set("export:#{id}", "queued")
q.call("XADD", "exports", "*", "id", id)
end
render json: { id: id }, status: 202
end
def status
state = NotesStore.queue { |q| q.get("export:#{params[:id]}") }
render json: { status: state || "unknown" }, status: state ? 200 : 404
end
def download
id = params[:id]
state = NotesStore.queue { |q| q.get("export:#{id}") }
unless id.match?(/\A[0-9a-f-]{36}\z/) && state == "ready"
return render json: { error: "Export not ready" }, status: 404
end
data = NotesStore.queue { |q| NotesStore.load_export(q, id) }
send_data data, type: "application/json", filename: "notes.json"
end
end
Review production preparation before adapting these handlers to real users.
Troubleshoot and clean up Ruby on Rails
Diagnose a failed checkpoint
Commit Gemfile.lock with a Linux platform entry (bundle lock --add-platform x86_64-linux). A missing SECRET_KEY_BASE is fixed by the linked Rails secret. MariaDB uses Trilogy; do not replace its connection scheme with an arbitrary string. The API-only app does not need an asset compilation step.
| Symptom | Check |
|---|---|
| No build starts | The Environment uses release source Build, the pushed branch is main, and both apps use the shared repository root. Review framework availability. |
| Build fails detection | Check the manifest and lockfiles in the committed source, not just local files. See the hello chapter’s file list. |
| Health check fails | Port is 3000, binding is 0.0.0.0, path is /health, and the web app selects web. |
| Notes returns 503 | Primary attachment is DATABASE_URL, engine matches its type, database is running, and the schema command printed Schema ready. |
| Export stays queued | Worker selects worker, has its own database and REDIS_URL attachments, and has one running replica. A recovered pending job waits up to 30 seconds. |
| Download fails after adding S3 | Attach the bucket to both apps with no prefix, wait for their restart, and create a new export. Old Redis-only exports have no S3 object. |
| Forbidden operation | Confirm your project capabilities for deploying, databases, storage, or secrets; ask the project administrator to grant the needed access. |
Dashboard: inspect the app’s Deployments, Logs, and Config tabs. The Process field is also editable under Settings; clearing it restores the buildpack’s default process.
CLI:
paas ps -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas logs -a notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rails
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rails
Do not print connection strings or credentials into logs. The example’s error responses identify the failing checkpoint without exposing a password.
Before deleting the example, review Take an Example to Production if you plan to adapt it. For platform-wide symptoms, see Troubleshooting & FAQ.
Clean up
Keep anything you want to retain by downloading exports first. Deleting this tutorial Environment permanently removes its apps and data resources; it is not a way to pause them.
Dashboard: open Project Examples → Environment rails → Settings → Delete Environment,
then confirm its name. Make sure this Environment contains only the tutorial resources.
CLI: review its resources, then confirm deletion:
paas apps list -o "$TUTORIAL_ORG" -p examples -e rails
paas dbs list -o "$TUTORIAL_ORG" -p examples -e rails
paas envs delete rails -o "$TUTORIAL_ORG" -p examples --confirm rails
This removes the web/worker, primary and optional databases, Redis, and tutorial bucket
created in this Environment. If you used local services, run docker compose down -v
from the example folder to discard that separate local dataset.
Python and Flask: notes and background exports
You will use Python 3.13 and build a web app named notes-web plus a worker
named notes-worker in the examples Project’s python Environment.
Flask defines the routes and Gunicorn serves them in the deployed web process. Psycopg, PyMySQL, and PyMongo handle persistence. The committed Procfile is required by the Python builder.
Complete Before you start, then follow these chapters in order:
- Create and deploy hello
- Persist notes in PostgreSQL
- Add a Redis export worker
- Save exports in object storage
- Try MariaDB or MongoDB instead
- Prepare for production, or troubleshoot and clean up
Downloads: hello source · complete notes source. The source walkthrough is included directly from those runnable files.
1. Create and deploy a Python and Flask hello app
You will see a hello response at / and ok at /health before adding any services.
1. Download and run locally
Download the hello ZIP into a working directory, then open a terminal in that directory:
unzip python-hello.zip
cd python-hello
python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
gunicorn --bind 0.0.0.0:3000 app:app
Keep it running. In another terminal, curl -fsS http://localhost:3000/health should print ok.
Open http://localhost:3000/ and confirm the hello message. Stop the server with Ctrl+C.
The app source is small enough to inspect in full:
.python-version
3.13
requirements.txt
blinker==1.9.0
click==8.5.0
Flask==3.1.3
gunicorn==23.0.0
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
packaging==26.3
Werkzeug==3.1.9
Procfile
web: gunicorn --bind 0.0.0.0:$PORT app:app
app.py
import os
from flask import Flask
app = Flask(__name__)
@app.get('/')
def index():
return 'Hello from Python on d2o!\n'
@app.get('/health')
def health():
return 'ok\n'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.getenv('PORT', '3000')))
Keep the supplied lockfiles in Git. The buildpack uses these manifests and its runtime version declaration to reproduce the app’s dependencies.
2. Create the Environment
Dashboard: open Project Examples → create Environment python, type Development,
branch main, release source Build, Automatic deployment on.
CLI:
paas envs create python -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
3. Create the web app
Dashboard: inside python choose Add Resource → Web Application.
Name it notes-web, choose Framework Python,
Repository The environment’s shared repository, Directory blank (repository root),
and Process type web. Use Port 3000, Health Check Path /health, and one replica.
Click Create Resource.
CLI:
paas apps create notes-web --framework python --shared-source --start-command web --port 3000 --health-check-path /health -o "$TUTORIAL_ORG" -p examples -e python
4. Commit and push
Initialize a repository in the unpacked hello directory:
git init -b main
git add .
git commit -m "Add hello application"
Dashboard path: open the Environment’s Git remote panel, copy its shared-repository remote command into this directory, then run its push command. This is the Environment remote, not a per-app remote. The dashboard supplies the correct host and authentication URL.
CLI path: omit -a when setting the shared remote:
paas git remote -o "$TUTORIAL_ORG" -p examples -e python
git push d2o main
paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
5. Verify the deployed app
Dashboard: open notes-web → Deployments, wait for the successful build and running
instance, then open its public URL. Append /health; it should display ok.
CLI: paas ps and paas logs with -o "$TUTORIAL_ORG" -p examples -e python and -a notes-web show the running process.
Use the public URL displayed in the dashboard, or read the url field from
paas apps info notes-web --json with the same Organization, Project, and Environment flags.
APP_URL=https://your-app-host
curl -fsS "$APP_URL/health"
curl -fsS "$APP_URL/"
Set APP_URL to your actual URL, with no trailing slash. If the build fails, see
troubleshooting before continuing.
Next: Persist notes in PostgreSQL.
2. Persist notes in PostgreSQL
Replace hello with the notes checkpoint, attach PostgreSQL, and create the schema once. The Redis and S3 code is present but is not used until you request an export.
1. Replace the source checkpoint
Download the complete notes ZIP beside your hello directory.
From inside python-hello, run:
cd ..
unzip python-notes.zip
cp -R python-notes/. python-hello/
cd python-hello
python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
This retains the hello directory’s .git history and shared remote. The source appendix
shows the database implementation. Notes use UUID IDs, validated text,
and parameterized SQL or the MongoDB driver; no file on the app instance stores notes.
For an optional local run, follow Local services and run
python app.py migrate before gunicorn --bind 0.0.0.0:3000 app:app.
2. Create and attach PostgreSQL
Dashboard: in python add a Database named notes-db, engine PostgreSQL, with
1 GB storage. Wait until running. Open notes-web → Config → Attached Resources,
select notes-db, leave the variable name blank (DATABASE_URL), and click Attach.
In the same Config tab add plain variable DATABASE_ENGINE=postgresql.
CLI:
paas dbs create notes-db --type postgresql --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e python
paas dbs info notes-db -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-web notes-db -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=postgresql -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
Wait for database provisioning before trying to initialize the schema. The attachment
injects DATABASE_URL; never copy credentials into the source or .env in Git.
Check attachment names with paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e python.
3. Deploy the notes app
git add .
git commit -m "Add persistent notes and export processes"
git push d2o main
Dashboard: wait for notes-web to be running in its Deployments/Overview view.
CLI: run paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e python. /health succeeds even before the schema exists;
/notes may return 503 until the next step.
4. Initialize the schema
Dashboard: notes-web → Console → select a running instance → Connect. Run:
/cnb/process/migrate
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e python -- /cnb/process/migrate
The migrate process is declared in the Procfile. Calling /cnb/process/migrate
loads the buildpack runtime environment, including the language executable and dependencies.
Expect Schema ready. The command creates the notes table if missing, so it is safe to
repeat for this example. paas run does not relay the remote exit code: inspect the output
and verify /notes, rather than assuming a zero CLI exit means the schema succeeded.
5. Check persistence
Open the app URL, enter a sample note, and choose Save note. Refresh; the note remains. You can also test from your terminal:
curl -fsS -H "Content-Type: application/json" -d '{"body":"My first persistent note"}' "$APP_URL/notes"
curl -fsS "$APP_URL/notes"
Restart notes-web from its dashboard action or run paas restart -a notes-web -o "$TUTORIAL_ORG" -p examples -e python.
Wait for it to be running and fetch /notes again; the note must still be present.
An empty body returns HTTP 400. Leave Export notes for the next chapter.
For production, replace manual schema setup with versioned release-command migrations.
Next: Add a Redis export worker.
3. Add a Redis export worker
The web app queues a job and immediately returns its ID. A separate process reads the notes and generates JSON. This chapter stores that JSON in Redis; the next moves it to S3.
1. Create Redis
Dashboard: in python add Database notes-queue, engine Redis, 1 GB storage;
wait until running. In notes-web → Config, attach it with variable name REDIS_URL.
Do not use the default DATABASE_URL, which already points at PostgreSQL.
CLI:
paas dbs create notes-queue --type redis --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-web notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e python
2. Create the worker from the same code
Dashboard: add a Background Worker named notes-worker, Framework python
(the same selection as the web app), Repository The environment’s shared repository,
Directory blank, Process type worker, one replica. A worker has no public HTTP endpoint.
Open its Config tab, set DATABASE_ENGINE=postgresql, and attach both notes-db as
DATABASE_URL and notes-queue as REDIS_URL.
CLI:
paas apps create notes-worker --worker --framework python --shared-source --process worker -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=postgresql -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-worker notes-db -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-worker notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e python
The worker must have its own attachments; the web app’s configuration is not inherited. The process value is a named buildpack process, not a shell command. See the worker source for the job loop.
3. Push and check the worker
Trigger a shared build after both apps have their configuration:
git commit --allow-empty -m "Deploy web and export worker"
git push d2o main
Dashboard: wait for both apps to run, then open notes-worker → Logs and look for
Worker ready. CLI: paas watch -o "$TUTORIAL_ORG" -p examples -e python follows both apps; then use
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python. The web app must still answer /health.
4. Export and recover a queued job
In the browser, click Export notes, wait for the download link, and open the JSON.
It must contain your saved note. Logs show Export ready: <id>.
To check queueing while the worker is unavailable, open its dashboard Overview and use − in the Replicas row to reach 0,
then confirm the stop, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python. Request an export; it stays queued.
Use + in the same row to restore replicas to 1 (or paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python); the same job should complete.
If the UI polling times out after 90 seconds, query /exports/<id> and download from
/exports/<id>/download once ready, or submit another export.
The worker uses Redis Streams with a consumer group. It acknowledges only after saving the result. A replacement worker claims unacknowledged jobs idle for 30 seconds; writes use the same export ID so a retried job overwrites the same result. Failures remain queued for retry and appear in worker logs. This is at-least-once delivery, not exactly-once delivery. Do not use this loop unchanged for side effects such as charging a payment.
Redis itself is not backed up by the platform. For essential jobs, retain enough information in the primary database to recreate or reconcile them. See durability and recovery and production preparation.
Next: Save exports in object storage.
4. Save exports in object storage
Keep notes in the database and move newly generated JSON exports into a private bucket. The web app streams downloads from S3; browser code never receives bucket credentials.
1. Create the bucket
Dashboard: in python add Object Storage, name notes-exports, tier Capacity,
quota 1 GB. Leave it private and wait until provisioned. Capacity is suitable here
because exports can be recreated from notes. Use High availability for irreplaceable files.
CLI:
paas buckets create notes-exports --tier capacity --quota-gb 1 -o "$TUTORIAL_ORG" -p examples -e python
paas buckets info -a notes-exports -o "$TUTORIAL_ORG" -p examples -e python
Wait for available: true; keep public: false.
2. Attach the bucket to both apps
Dashboard: open each app’s Config tab, choose bucket notes-exports in the attachment
card, leave the credential prefix blank, and click Attach.
CLI:
paas apps attach notes-web notes-exports -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-worker notes-exports -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e python
The primary attachment injects S3_ENDPOINT, S3_BUCKET, S3_REGION,
AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY. The storage implementation
passes the endpoint and region explicitly and uses path-style S3 requests.
Do not add these credentials to frontend code, Git, or ordinary config variables.
Wait for both apps to restart with the new attachment before testing.
3. Generate a new export
Open the app and request a new export. Exports made before the bucket attachment still live in Redis, so their old download links no longer work while S3 mode is enabled. Download the new JSON and confirm your note appears.
Dashboard: open notes-exports → Files → exports/. The file named <job-id>.json
should be present. CLI:
paas buckets ls exports/ -a notes-exports -o "$TUTORIAL_ORG" -p examples -e python
A direct anonymous object request should return 403; downloading through the app succeeds
because its server uses the attachment credentials. The bucket stays private, but the example
app itself has no user authentication. Do not enter confidential data.
4. Verify durability and diagnose failures
Restart notes-worker, submit another export, and check that it completes. Restart notes-web
and download the completed export again. No result depends on either app’s local filesystem.
If a job stays queued, check worker logs, both apps’ attachment lists, and bucket readiness.
After fixing a failed S3 write, the unacknowledged job retries after its 30-second idle interval.
Export files and Redis status records have no expiration in this teaching example. Delete unneeded files in the bucket’s Files tab, or clean up the entire isolated Environment when done.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MariaDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes, Redis queue, worker, and object-storage integration. The app selects the same SQL notes table, through its MariaDB driver. This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in python create Database notes-mariadb, engine MariaDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mariadb --type mariadb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e python
paas dbs info notes-mariadb -o "$TUTORIAL_ORG" -p examples -e python
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mariadb using DATABASE_URL,
then set DATABASE_ENGINE=mariadb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e python
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e python
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-web notes-mariadb -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-worker notes-mariadb -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=mariadb -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=mariadb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e python -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python. Create a new note identifying MariaDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MongoDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes,
Redis queue, worker, and object-storage integration. The app selects a notes collection with a unique id index, through its MongoDB driver.
This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in python create Database notes-mongodb, engine MongoDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mongodb --type mongodb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e python
paas dbs info notes-mongodb -o "$TUTORIAL_ORG" -p examples -e python
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mongodb using DATABASE_URL,
then set DATABASE_ENGINE=mongodb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e python
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e python
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-web notes-mongodb -o "$TUTORIAL_ORG" -p examples -e python
paas apps attach notes-worker notes-mongodb -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=mongodb -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas config set DATABASE_ENGINE=mongodb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e python -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python. Create a new note identifying MongoDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Python and Flask source walkthrough
The runnable source below is included from the same files packaged in the notes download. Lockfiles, configuration, and the shared browser UI are included in the download.
Database
A notes request reaches this adapter after input validation. DATABASE_ENGINE chooses
PostgreSQL, MariaDB, or MongoDB; DATABASE_URL comes from the app’s attachment. SQL uses
parameters rather than interpolating note text, and MongoDB uses driver operations.
The initialization process creates a table or unique index. Records live in the database,
so replacing a web instance leaves them available to its replacement.
@contextmanager
def sql():
url = os.environ['DATABASE_URL']
if engine == 'postgresql':
connection = psycopg.connect(url, row_factory=dict_row)
elif engine == 'mariadb':
parts = urlparse(url)
connection = pymysql.connect(host=parts.hostname, port=parts.port or 3306,
user=unquote(parts.username or ''), password=unquote(parts.password or ''),
database=parts.path.lstrip('/'), cursorclass=pymysql.cursors.DictCursor)
else:
raise ValueError('Unsupported DATABASE_ENGINE')
try:
yield connection
connection.commit()
finally:
connection.close()
def collection():
global mongo
if mongo is None:
mongo = MongoClient(os.environ['DATABASE_URL'], serverSelectionTimeoutMS=5000)
return mongo.get_default_database()['notes']
def migrate():
if engine == 'mongodb':
collection().create_index('id', unique=True)
else:
with sql() as db, db.cursor() as cursor:
cursor.execute('CREATE TABLE IF NOT EXISTS notes (id VARCHAR(36) PRIMARY KEY, body TEXT NOT NULL)')
print('Schema ready', flush=True)
def notes():
if engine == 'mongodb':
return list(collection().find({}, {'_id': 0}).sort('id', 1))
with sql() as db, db.cursor() as cursor:
cursor.execute('SELECT id, body FROM notes ORDER BY id')
return list(cursor.fetchall())
def add_note(body):
note = {'id': str(uuid.uuid4()), 'body': body}
if engine == 'mongodb':
collection().insert_one(dict(note))
else:
with sql() as db, db.cursor() as cursor:
cursor.execute('INSERT INTO notes (id, body) VALUES (%s, %s)', (note['id'], body))
return note
Worker
Creating an export enqueues an ID and returns immediately. This loop reads the notes, writes an export under that ID, records its status, and acknowledges the stream entry. It also reclaims jobs abandoned before acknowledgement. Retrying writes the same export key, so interruption does not create an additional export file; the snapshot can reflect newer notes if they changed before the retry. See worker recovery.
def worker():
client, consumer = queue(), str(uuid.uuid4())
try:
client.xgroup_create('exports', 'workers', id='0', mkstream=True)
except redis.ResponseError as error:
if 'BUSYGROUP' not in str(error):
raise
stopping = False
def stop(*_args):
nonlocal stopping
stopping = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print('Worker ready', flush=True)
while not stopping:
try:
messages = client.xautoclaim('exports', 'workers', consumer, 30000, '0-0', count=1)[1]
if not messages:
result = client.xreadgroup('workers', consumer, {'exports': '>'}, count=1, block=5000)
messages = result[0][1] if result else []
for message_id, fields in messages:
export_id = fields.get('id', '')
if not re.fullmatch(r'[0-9a-f-]{36}', export_id):
client.xack('exports', 'workers', message_id)
continue
if client.get(f'export:{export_id}') != 'ready':
save_export(client, export_id, json.dumps(notes(), indent=2))
client.set(f'export:{export_id}', 'ready')
client.xack('exports', 'workers', message_id)
client.xdel('exports', message_id)
print(f'Export ready: {export_id}', flush=True)
except Exception:
print('Export failed; unacknowledged jobs retry after 30 seconds. Check attachments and schema.', flush=True)
time.sleep(1)
Object storage
Without S3 configuration, exports are stored in Redis for the worker checkpoint. With
an attached bucket, the worker writes JSON to exports/<id>.json and the web process
reads that object for downloads. Both processes need their own bucket attachment.
The client uses the supplied endpoint and region with path-style requests. Credentials
stay on the server; a private bucket does not add authentication to the example’s public download route.
def storage():
return boto3.client('s3', endpoint_url=os.environ['S3_ENDPOINT'],
region_name=os.environ['S3_REGION'], config=Config(s3={'addressing_style': 'path'}))
def save_export(client, export_id, data):
if os.getenv('S3_BUCKET'):
storage().put_object(Bucket=os.environ['S3_BUCKET'], Key=f'exports/{export_id}.json',
Body=data.encode(), ContentType='application/json')
else:
client.set(f'export:{export_id}:data', data)
def load_export(client, export_id):
if os.getenv('S3_BUCKET'):
return storage().get_object(Bucket=os.environ['S3_BUCKET'],
Key=f'exports/{export_id}.json')['Body'].read()
return client.get(f'export:{export_id}:data')
Complete application
The complete source and startup commands are in the notes ZIP linked above. The HTTP
layer connects the three components: POST /notes validates and saves a record,
GET /notes lists records, POST /exports queues work, and the export status and
download routes expose the result. Follow the language’s tutorial sequence
to exercise each part, then review production preparation.
Troubleshoot and clean up Python and Flask
Diagnose a failed checkpoint
Keep Procfile at the repository root and commit requirements.txt plus .python-version. Gunicorn must bind 0.0.0.0:$PORT; the worker process is python app.py worker.
| Symptom | Check |
|---|---|
| No build starts | The Environment uses release source Build, the pushed branch is main, and both apps use the shared repository root. Review framework availability. |
| Build fails detection | Check the manifest and lockfiles in the committed source, not just local files. See the hello chapter’s file list. |
| Health check fails | Port is 3000, binding is 0.0.0.0, path is /health, and the web app selects web. |
| Notes returns 503 | Primary attachment is DATABASE_URL, engine matches its type, database is running, and the schema command printed Schema ready. |
| Export stays queued | Worker selects worker, has its own database and REDIS_URL attachments, and has one running replica. A recovered pending job waits up to 30 seconds. |
| Download fails after adding S3 | Attach the bucket to both apps with no prefix, wait for their restart, and create a new export. Old Redis-only exports have no S3 object. |
| Forbidden operation | Confirm your project capabilities for deploying, databases, storage, or secrets; ask the project administrator to grant the needed access. |
Dashboard: inspect the app’s Deployments, Logs, and Config tabs. The Process field is also editable under Settings; clearing it restores the buildpack’s default process.
CLI:
paas ps -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas logs -a notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e python
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e python
Do not print connection strings or credentials into logs. The example’s error responses identify the failing checkpoint without exposing a password.
Before deleting the example, review Take an Example to Production if you plan to adapt it. For platform-wide symptoms, see Troubleshooting & FAQ.
Clean up
Keep anything you want to retain by downloading exports first. Deleting this tutorial Environment permanently removes its apps and data resources; it is not a way to pause them.
Dashboard: open Project Examples → Environment python → Settings → Delete Environment,
then confirm its name. Make sure this Environment contains only the tutorial resources.
CLI: review its resources, then confirm deletion:
paas apps list -o "$TUTORIAL_ORG" -p examples -e python
paas dbs list -o "$TUTORIAL_ORG" -p examples -e python
paas envs delete python -o "$TUTORIAL_ORG" -p examples --confirm python
This removes the web/worker, primary and optional databases, Redis, and tutorial bucket
created in this Environment. If you used local services, run docker compose down -v
from the example folder to discard that separate local dataset.
Go: notes and background exports
For an existing Go project, use the shorter Deploying Go guide. This tutorial builds a complete example step by step.
You will use Go 1.27.1 and build a web app named notes-web plus a worker
named notes-worker in the examples Project’s go Environment.
The standard library serves HTTP. The database/sql drivers, MongoDB driver, Redis client, and AWS SDK implement the service integrations. The Procfile selects the web or worker mode of the same binary.
Complete Before you start, then follow these chapters in order:
- Create and deploy hello
- Persist notes in PostgreSQL
- Add a Redis export worker
- Save exports in object storage
- Try MariaDB or MongoDB instead
- Prepare for production, or troubleshoot and clean up
Downloads: hello source · complete notes source. The source walkthrough is included directly from those runnable files.
1. Create and deploy a Go hello app
You will see a hello response at / and ok at /health before adding any services.
1. Download and run locally
Download the hello ZIP into a working directory, then open a terminal in that directory:
unzip go-hello.zip
cd go-hello
go mod download
go run . web
Keep it running. In another terminal, curl -fsS http://localhost:3000/health should print ok.
Open http://localhost:3000/ and confirm the hello message. Stop the server with Ctrl+C.
The app source is small enough to inspect in full:
go.mod
module example.com/hello
go 1.27.1
Procfile
web: hello
main.go
package main
import ("fmt"; "log"; "net/http"; "os"; "time")
func main() {
port:=os.Getenv("PORT"); if port=="" {port="3000"}
mux:=http.NewServeMux()
mux.HandleFunc("/",func(w http.ResponseWriter,r *http.Request){fmt.Fprintln(w,"Hello from Go on d2o!")})
mux.HandleFunc("/health",func(w http.ResponseWriter,r *http.Request){fmt.Fprintln(w,"ok")})
server:=&http.Server{Addr:"0.0.0.0:"+port,Handler:mux,ReadHeaderTimeout:5*time.Second}
log.Fatal(server.ListenAndServe())
}
Keep the supplied lockfiles in Git. The buildpack uses these manifests and its runtime version declaration to reproduce the app’s dependencies.
2. Create the Environment
Dashboard: open Project Examples → create Environment go, type Development,
branch main, release source Build, Automatic deployment on.
CLI:
paas envs create go -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
3. Create the web app
Dashboard: inside go choose Add Resource → Web Application.
Name it notes-web, choose Framework Go,
Repository The environment’s shared repository, Directory blank (repository root),
and Process type web. Use Port 3000, Health Check Path /health, and one replica.
Click Create Resource.
CLI:
paas apps create notes-web --framework go --shared-source --start-command web --port 3000 --health-check-path /health -o "$TUTORIAL_ORG" -p examples -e go
4. Commit and push
Initialize a repository in the unpacked hello directory:
git init -b main
git add .
git commit -m "Add hello application"
Dashboard path: open the Environment’s Git remote panel, copy its shared-repository remote command into this directory, then run its push command. This is the Environment remote, not a per-app remote. The dashboard supplies the correct host and authentication URL.
CLI path: omit -a when setting the shared remote:
paas git remote -o "$TUTORIAL_ORG" -p examples -e go
git push d2o main
paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
5. Verify the deployed app
Dashboard: open notes-web → Deployments, wait for the successful build and running
instance, then open its public URL. Append /health; it should display ok.
CLI: paas ps and paas logs with -o "$TUTORIAL_ORG" -p examples -e go and -a notes-web show the running process.
Use the public URL displayed in the dashboard, or read the url field from
paas apps info notes-web --json with the same Organization, Project, and Environment flags.
APP_URL=https://your-app-host
curl -fsS "$APP_URL/health"
curl -fsS "$APP_URL/"
Set APP_URL to your actual URL, with no trailing slash. If the build fails, see
troubleshooting before continuing.
Next: Persist notes in PostgreSQL.
2. Persist notes in PostgreSQL
Replace hello with the notes checkpoint, attach PostgreSQL, and create the schema once. The Redis and S3 code is present but is not used until you request an export.
1. Replace the source checkpoint
Download the complete notes ZIP beside your hello directory.
From inside go-hello, run:
cd ..
unzip go-notes.zip
cp -R go-notes/. go-hello/
cd go-hello
go mod download
This retains the hello directory’s .git history and shared remote. The source appendix
shows the database implementation. Notes use UUID IDs, validated text,
and parameterized SQL or the MongoDB driver; no file on the app instance stores notes.
For an optional local run, follow Local services and run
go run . migrate before go run . web.
2. Create and attach PostgreSQL
Dashboard: in go add a Database named notes-db, engine PostgreSQL, with
1 GB storage. Wait until running. Open notes-web → Config → Attached Resources,
select notes-db, leave the variable name blank (DATABASE_URL), and click Attach.
In the same Config tab add plain variable DATABASE_ENGINE=postgresql.
CLI:
paas dbs create notes-db --type postgresql --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e go
paas dbs info notes-db -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-web notes-db -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=postgresql -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
Wait for database provisioning before trying to initialize the schema. The attachment
injects DATABASE_URL; never copy credentials into the source or .env in Git.
Check attachment names with paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e go.
3. Deploy the notes app
git add .
git commit -m "Add persistent notes and export processes"
git push d2o main
Dashboard: wait for notes-web to be running in its Deployments/Overview view.
CLI: run paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e go. /health succeeds even before the schema exists;
/notes may return 503 until the next step.
4. Initialize the schema
Dashboard: notes-web → Console → select a running instance → Connect. Run:
/cnb/process/migrate
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e go -- /cnb/process/migrate
The migrate process is declared in the Procfile. Calling /cnb/process/migrate
loads the buildpack runtime environment, including the language executable and dependencies.
Expect Schema ready. The command creates the notes table if missing, so it is safe to
repeat for this example. paas run does not relay the remote exit code: inspect the output
and verify /notes, rather than assuming a zero CLI exit means the schema succeeded.
5. Check persistence
Open the app URL, enter a sample note, and choose Save note. Refresh; the note remains. You can also test from your terminal:
curl -fsS -H "Content-Type: application/json" -d '{"body":"My first persistent note"}' "$APP_URL/notes"
curl -fsS "$APP_URL/notes"
Restart notes-web from its dashboard action or run paas restart -a notes-web -o "$TUTORIAL_ORG" -p examples -e go.
Wait for it to be running and fetch /notes again; the note must still be present.
An empty body returns HTTP 400. Leave Export notes for the next chapter.
For production, replace manual schema setup with versioned release-command migrations.
Next: Add a Redis export worker.
3. Add a Redis export worker
The web app queues a job and immediately returns its ID. A separate process reads the notes and generates JSON. This chapter stores that JSON in Redis; the next moves it to S3.
1. Create Redis
Dashboard: in go add Database notes-queue, engine Redis, 1 GB storage;
wait until running. In notes-web → Config, attach it with variable name REDIS_URL.
Do not use the default DATABASE_URL, which already points at PostgreSQL.
CLI:
paas dbs create notes-queue --type redis --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-web notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e go
2. Create the worker from the same code
Dashboard: add a Background Worker named notes-worker, Framework go
(the same selection as the web app), Repository The environment’s shared repository,
Directory blank, Process type worker, one replica. A worker has no public HTTP endpoint.
Open its Config tab, set DATABASE_ENGINE=postgresql, and attach both notes-db as
DATABASE_URL and notes-queue as REDIS_URL.
CLI:
paas apps create notes-worker --worker --framework go --shared-source --process worker -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=postgresql -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-worker notes-db -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-worker notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e go
The worker must have its own attachments; the web app’s configuration is not inherited. The process value is a named buildpack process, not a shell command. See the worker source for the job loop.
3. Push and check the worker
Trigger a shared build after both apps have their configuration:
git commit --allow-empty -m "Deploy web and export worker"
git push d2o main
Dashboard: wait for both apps to run, then open notes-worker → Logs and look for
Worker ready. CLI: paas watch -o "$TUTORIAL_ORG" -p examples -e go follows both apps; then use
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go. The web app must still answer /health.
4. Export and recover a queued job
In the browser, click Export notes, wait for the download link, and open the JSON.
It must contain your saved note. Logs show Export ready: <id>.
To check queueing while the worker is unavailable, open its dashboard Overview and use − in the Replicas row to reach 0,
then confirm the stop, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go. Request an export; it stays queued.
Use + in the same row to restore replicas to 1 (or paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go); the same job should complete.
If the UI polling times out after 90 seconds, query /exports/<id> and download from
/exports/<id>/download once ready, or submit another export.
The worker uses Redis Streams with a consumer group. It acknowledges only after saving the result. A replacement worker claims unacknowledged jobs idle for 30 seconds; writes use the same export ID so a retried job overwrites the same result. Failures remain queued for retry and appear in worker logs. This is at-least-once delivery, not exactly-once delivery. Do not use this loop unchanged for side effects such as charging a payment.
Redis itself is not backed up by the platform. For essential jobs, retain enough information in the primary database to recreate or reconcile them. See durability and recovery and production preparation.
Next: Save exports in object storage.
4. Save exports in object storage
Keep notes in the database and move newly generated JSON exports into a private bucket. The web app streams downloads from S3; browser code never receives bucket credentials.
1. Create the bucket
Dashboard: in go add Object Storage, name notes-exports, tier Capacity,
quota 1 GB. Leave it private and wait until provisioned. Capacity is suitable here
because exports can be recreated from notes. Use High availability for irreplaceable files.
CLI:
paas buckets create notes-exports --tier capacity --quota-gb 1 -o "$TUTORIAL_ORG" -p examples -e go
paas buckets info -a notes-exports -o "$TUTORIAL_ORG" -p examples -e go
Wait for available: true; keep public: false.
2. Attach the bucket to both apps
Dashboard: open each app’s Config tab, choose bucket notes-exports in the attachment
card, leave the credential prefix blank, and click Attach.
CLI:
paas apps attach notes-web notes-exports -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-worker notes-exports -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e go
The primary attachment injects S3_ENDPOINT, S3_BUCKET, S3_REGION,
AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY. The storage implementation
passes the endpoint and region explicitly and uses path-style S3 requests.
Do not add these credentials to frontend code, Git, or ordinary config variables.
Wait for both apps to restart with the new attachment before testing.
3. Generate a new export
Open the app and request a new export. Exports made before the bucket attachment still live in Redis, so their old download links no longer work while S3 mode is enabled. Download the new JSON and confirm your note appears.
Dashboard: open notes-exports → Files → exports/. The file named <job-id>.json
should be present. CLI:
paas buckets ls exports/ -a notes-exports -o "$TUTORIAL_ORG" -p examples -e go
A direct anonymous object request should return 403; downloading through the app succeeds
because its server uses the attachment credentials. The bucket stays private, but the example
app itself has no user authentication. Do not enter confidential data.
4. Verify durability and diagnose failures
Restart notes-worker, submit another export, and check that it completes. Restart notes-web
and download the completed export again. No result depends on either app’s local filesystem.
If a job stays queued, check worker logs, both apps’ attachment lists, and bucket readiness.
After fixing a failed S3 write, the unacknowledged job retries after its 30-second idle interval.
Export files and Redis status records have no expiration in this teaching example. Delete unneeded files in the bucket’s Files tab, or clean up the entire isolated Environment when done.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MariaDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes, Redis queue, worker, and object-storage integration. The app selects the same SQL notes table, through its MariaDB driver. This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in go create Database notes-mariadb, engine MariaDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mariadb --type mariadb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e go
paas dbs info notes-mariadb -o "$TUTORIAL_ORG" -p examples -e go
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mariadb using DATABASE_URL,
then set DATABASE_ENGINE=mariadb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e go
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e go
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-web notes-mariadb -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-worker notes-mariadb -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=mariadb -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=mariadb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e go -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go. Create a new note identifying MariaDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MongoDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes,
Redis queue, worker, and object-storage integration. The app selects a notes collection with a unique id index, through its MongoDB driver.
This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in go create Database notes-mongodb, engine MongoDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mongodb --type mongodb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e go
paas dbs info notes-mongodb -o "$TUTORIAL_ORG" -p examples -e go
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mongodb using DATABASE_URL,
then set DATABASE_ENGINE=mongodb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e go
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e go
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-web notes-mongodb -o "$TUTORIAL_ORG" -p examples -e go
paas apps attach notes-worker notes-mongodb -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=mongodb -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas config set DATABASE_ENGINE=mongodb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e go -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go. Create a new note identifying MongoDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Go source walkthrough
The runnable source below is included from the same files packaged in the notes download. Lockfiles, configuration, and the shared browser UI are included in the download.
Database
A notes request reaches this adapter after input validation. DATABASE_ENGINE chooses
PostgreSQL, MariaDB, or MongoDB; DATABASE_URL comes from the app’s attachment. SQL uses
parameters rather than interpolating note text, and MongoDB uses driver operations.
The initialization process creates a table or unique index. Records live in the database,
so replacing a web instance leaves them available to its replacement.
func database(ctx context.Context) error {
dbMu.Lock()
defer dbMu.Unlock()
if db != nil || collection != nil {
return nil
}
raw := os.Getenv("DATABASE_URL")
if raw == "" {
return errors.New("attach a database first")
}
u, err := url.Parse(raw)
if err != nil {
return err
}
switch engine {
case "mongodb":
client, err := mongo.Connect(ctx, options.Client().ApplyURI(raw).SetServerSelectionTimeout(5*time.Second))
if err != nil {
return err
}
collection = client.Database(strings.TrimPrefix(u.Path, "/")).Collection("notes")
case "postgresql":
db, err = sql.Open("pgx", raw)
case "mariadb":
c := mysql.NewConfig()
c.Net = "tcp"
c.Addr = net.JoinHostPort(u.Hostname(), envPort(u.Port(), "3306"))
c.DBName = strings.TrimPrefix(u.Path, "/")
if u.User != nil {
c.User = u.User.Username()
c.Passwd, _ = u.User.Password()
}
db, err = sql.Open("mysql", c.FormatDSN())
default:
return errors.New("unsupported DATABASE_ENGINE")
}
if db != nil {
db.SetMaxOpenConns(5)
}
return err
}
func envPort(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
func migrate(ctx context.Context) error {
if err := database(ctx); err != nil {
return err
}
if engine == "mongodb" {
_, err := collection.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "id", Value: 1}}, Options: options.Index().SetUnique(true)})
return err
}
_, err := db.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS notes (id VARCHAR(36) PRIMARY KEY, body TEXT NOT NULL)")
return err
}
func notes(ctx context.Context) ([]Note, error) {
result := []Note{}
if err := database(ctx); err != nil {
return nil, err
}
if engine == "mongodb" {
cursor, err := collection.Find(ctx, bson.D{}, options.Find().SetSort(bson.D{{Key: "id", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
err = cursor.All(ctx, &result)
return result, err
}
rows, err := db.QueryContext(ctx, "SELECT id, body FROM notes ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var n Note
if err = rows.Scan(&n.ID, &n.Body); err != nil {
return nil, err
}
result = append(result, n)
}
return result, rows.Err()
}
func addNote(ctx context.Context, body string) (Note, error) {
n := Note{ID: uuid.NewString(), Body: body}
if err := database(ctx); err != nil {
return n, err
}
if engine == "mongodb" {
_, err := collection.InsertOne(ctx, n)
return n, err
}
query := "INSERT INTO notes (id, body) VALUES ($1, $2)"
if engine == "mariadb" {
query = "INSERT INTO notes (id, body) VALUES (?, ?)"
}
_, err := db.ExecContext(ctx, query, n.ID, n.Body)
return n, err
}
Worker
Creating an export enqueues an ID and returns immediately. This loop reads the notes, writes an export under that ID, records its status, and acknowledges the stream entry. It also reclaims jobs abandoned before acknowledgement. Retrying writes the same export key, so interruption does not create an additional export file; the snapshot can reflect newer notes if they changed before the retry. See worker recovery.
func worker(ctx context.Context) error {
q, err := queue()
if err != nil {
return err
}
defer q.Close()
err = q.XGroupCreateMkStream(ctx, "exports", "workers", "0").Err()
if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
return err
}
consumer := uuid.NewString()
log.Print("Worker ready")
for ctx.Err() == nil {
messages, _, err := q.XAutoClaim(ctx, &redis.XAutoClaimArgs{Stream: "exports", Group: "workers", Consumer: consumer, MinIdle: 30 * time.Second, Start: "0-0", Count: 1}).Result()
if err == nil && len(messages) == 0 {
streams, readErr := q.XReadGroup(ctx, &redis.XReadGroupArgs{Group: "workers", Consumer: consumer, Streams: []string{"exports", ">"}, Count: 1, Block: 5 * time.Second}).Result()
err = readErr
if len(streams) > 0 {
messages = streams[0].Messages
}
}
if errors.Is(err, redis.Nil) {
continue
}
if err == nil {
for _, message := range messages {
err = process(ctx, q, message)
if err != nil {
break
}
}
}
if err != nil && ctx.Err() == nil {
log.Print("Export failed; unacknowledged jobs retry after 30 seconds. Check attachments and schema.")
time.Sleep(time.Second)
}
}
return nil
}
func process(ctx context.Context, q *redis.Client, message redis.XMessage) error {
id, _ := message.Values["id"].(string)
if _, err := uuid.Parse(id); err != nil {
return q.XAck(ctx, "exports", "workers", message.ID).Err()
}
state, err := q.Get(ctx, "export:"+id).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
if state != "ready" {
rows, err := notes(ctx)
if err != nil {
return err
}
data, err := json.MarshalIndent(rows, "", " ")
if err != nil {
return err
}
if err = saveExport(ctx, q, id, data); err != nil {
return err
}
if err = q.Set(ctx, "export:"+id, "ready", 0).Err(); err != nil {
return err
}
}
if err = q.XAck(ctx, "exports", "workers", message.ID).Err(); err != nil {
return err
}
log.Printf("Export ready: %s", id)
return q.XDel(ctx, "exports", message.ID).Err()
}
Object storage
Without S3 configuration, exports are stored in Redis for the worker checkpoint. With
an attached bucket, the worker writes JSON to exports/<id>.json and the web process
reads that object for downloads. Both processes need their own bucket attachment.
The client uses the supplied endpoint and region with path-style requests. Credentials
stay on the server; a private bucket does not add authentication to the example’s public download route.
func storage(ctx context.Context) (*s3.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(os.Getenv("S3_REGION")))
if err != nil {
return nil, err
}
return s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(os.Getenv("S3_ENDPOINT")); o.UsePathStyle = true }), nil
}
func saveExport(ctx context.Context, q *redis.Client, id string, data []byte) error {
if os.Getenv("S3_BUCKET") == "" {
return q.Set(ctx, "export:"+id+":data", data, 0).Err()
}
client, err := storage(ctx)
if err != nil {
return err
}
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(os.Getenv("S3_BUCKET")), Key: aws.String("exports/" + id + ".json"), Body: bytes.NewReader(data), ContentType: aws.String("application/json")})
return err
}
func loadExport(ctx context.Context, q *redis.Client, id string) ([]byte, error) {
if os.Getenv("S3_BUCKET") == "" {
return q.Get(ctx, "export:"+id+":data").Bytes()
}
client, err := storage(ctx)
if err != nil {
return nil, err
}
obj, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(os.Getenv("S3_BUCKET")), Key: aws.String("exports/" + id + ".json")})
if err != nil {
return nil, err
}
defer obj.Body.Close()
return io.ReadAll(obj.Body)
}
Complete application
The complete source and startup commands are in the notes ZIP linked above. The HTTP
layer connects the three components: POST /notes validates and saves a record,
GET /notes lists records, POST /exports queues work, and the export status and
download routes expose the result. Follow the language’s tutorial sequence
to exercise each part, then review production preparation.
Troubleshoot and clean up Go
Diagnose a failed checkpoint
Commit go.mod and go.sum. The module is named example.com/notes, so the built executable is notes on the buildpack PATH, as used in Procfile. Select worker for the background app, not web.
| Symptom | Check |
|---|---|
| No build starts | The Environment uses release source Build, the pushed branch is main, and both apps use the shared repository root. Review framework availability. |
| Build fails detection | Check the manifest and lockfiles in the committed source, not just local files. See the hello chapter’s file list. |
| Health check fails | Port is 3000, binding is 0.0.0.0, path is /health, and the web app selects web. |
| Notes returns 503 | Primary attachment is DATABASE_URL, engine matches its type, database is running, and the schema command printed Schema ready. |
| Export stays queued | Worker selects worker, has its own database and REDIS_URL attachments, and has one running replica. A recovered pending job waits up to 30 seconds. |
| Download fails after adding S3 | Attach the bucket to both apps with no prefix, wait for their restart, and create a new export. Old Redis-only exports have no S3 object. |
| Forbidden operation | Confirm your project capabilities for deploying, databases, storage, or secrets; ask the project administrator to grant the needed access. |
Dashboard: inspect the app’s Deployments, Logs, and Config tabs. The Process field is also editable under Settings; clearing it restores the buildpack’s default process.
CLI:
paas ps -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas logs -a notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e go
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e go
Do not print connection strings or credentials into logs. The example’s error responses identify the failing checkpoint without exposing a password.
Before deleting the example, review Take an Example to Production if you plan to adapt it. For platform-wide symptoms, see Troubleshooting & FAQ.
Clean up
Keep anything you want to retain by downloading exports first. Deleting this tutorial Environment permanently removes its apps and data resources; it is not a way to pause them.
Dashboard: open Project Examples → Environment go → Settings → Delete Environment,
then confirm its name. Make sure this Environment contains only the tutorial resources.
CLI: review its resources, then confirm deletion:
paas apps list -o "$TUTORIAL_ORG" -p examples -e go
paas dbs list -o "$TUTORIAL_ORG" -p examples -e go
paas envs delete go -o "$TUTORIAL_ORG" -p examples --confirm go
This removes the web/worker, primary and optional databases, Redis, and tutorial bucket
created in this Environment. If you used local services, run docker compose down -v
from the example folder to discard that separate local dataset.
Rust and Axum: notes and background exports
You will use Rust 1.99.0 through rustup and build a web app named notes-web plus a worker
named notes-worker in the examples Project’s rust Environment.
Axum serves HTTP. SQLx, the MongoDB driver, Redis client, and AWS SDK provide service integrations. Cargo builds separate web, worker, and migrate binaries; their names become buildpack process types. Commit Cargo.lock: the Rust builder requires it for detection.
Complete Before you start, then follow these chapters in order:
- Create and deploy hello
- Persist notes in PostgreSQL
- Add a Redis export worker
- Save exports in object storage
- Try MariaDB or MongoDB instead
- Prepare for production, or troubleshoot and clean up
Downloads: hello source · complete notes source. The source walkthrough is included directly from those runnable files.
1. Create and deploy a Rust and Axum hello app
You will see a hello response at / and ok at /health before adding any services.
1. Download and run locally
Download the hello ZIP into a working directory, then open a terminal in that directory:
unzip rust-hello.zip
cd rust-hello
cargo build --locked
cargo run --locked
Keep it running. In another terminal, curl -fsS http://localhost:3000/health should print ok.
Open http://localhost:3000/ and confirm the hello message. Stop the server with Ctrl+C.
The app source is small enough to inspect in full:
Cargo.toml
[package]
name = "web"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
rust-toolchain.toml
[toolchain]
channel = "1.99.0"
profile = "minimal"
src/main.rs
use axum::{Router, routing::get};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
.route("/", get(|| async { "Hello from Rust on d2o!\n" }))
.route("/health", get(|| async { "ok\n" }));
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
axum::serve(listener, app).await?;
Ok(())
}
Keep the supplied lockfiles in Git. The buildpack uses these manifests and its runtime version declaration to reproduce the app’s dependencies.
2. Create the Environment
Dashboard: open Project Examples → create Environment rust, type Development,
branch main, release source Build, Automatic deployment on.
CLI:
paas envs create rust -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
3. Create the web app
Dashboard: inside rust choose Add Resource → Web Application.
Name it notes-web, choose Framework Rust,
Repository The environment’s shared repository, Directory blank (repository root),
and Process type web. Use Port 3000, Health Check Path /health, and one replica.
Click Create Resource.
CLI:
paas apps create notes-web --framework rust --shared-source --start-command web --port 3000 --health-check-path /health -o "$TUTORIAL_ORG" -p examples -e rust
4. Commit and push
Initialize a repository in the unpacked hello directory:
git init -b main
git add .
git commit -m "Add hello application"
Dashboard path: open the Environment’s Git remote panel, copy its shared-repository remote command into this directory, then run its push command. This is the Environment remote, not a per-app remote. The dashboard supplies the correct host and authentication URL.
CLI path: omit -a when setting the shared remote:
paas git remote -o "$TUTORIAL_ORG" -p examples -e rust
git push d2o main
paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
5. Verify the deployed app
Dashboard: open notes-web → Deployments, wait for the successful build and running
instance, then open its public URL. Append /health; it should display ok.
CLI: paas ps and paas logs with -o "$TUTORIAL_ORG" -p examples -e rust and -a notes-web show the running process.
Use the public URL displayed in the dashboard, or read the url field from
paas apps info notes-web --json with the same Organization, Project, and Environment flags.
APP_URL=https://your-app-host
curl -fsS "$APP_URL/health"
curl -fsS "$APP_URL/"
Set APP_URL to your actual URL, with no trailing slash. If the build fails, see
troubleshooting before continuing.
Next: Persist notes in PostgreSQL.
2. Persist notes in PostgreSQL
Replace hello with the notes checkpoint, attach PostgreSQL, and create the schema once. The Redis and S3 code is present but is not used until you request an export.
1. Replace the source checkpoint
Download the complete notes ZIP beside your hello directory.
From inside rust-hello, run:
cd ..
unzip rust-notes.zip
cp -R rust-notes/. rust-hello/
cd rust-hello
rm src/main.rs
cargo build --locked
This retains the hello directory’s .git history and shared remote. The rm command
removes only the superseded hello source file in this tutorial directory. The source appendix
shows the database implementation. Notes use UUID IDs, validated text,
and parameterized SQL or the MongoDB driver; no file on the app instance stores notes.
For an optional local run, follow Local services and run
cargo run --locked --bin migrate before cargo run --locked --bin web.
2. Create and attach PostgreSQL
Dashboard: in rust add a Database named notes-db, engine PostgreSQL, with
1 GB storage. Wait until running. Open notes-web → Config → Attached Resources,
select notes-db, leave the variable name blank (DATABASE_URL), and click Attach.
In the same Config tab add plain variable DATABASE_ENGINE=postgresql.
CLI:
paas dbs create notes-db --type postgresql --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rust
paas dbs info notes-db -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-web notes-db -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=postgresql -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
Wait for database provisioning before trying to initialize the schema. The attachment
injects DATABASE_URL; never copy credentials into the source or .env in Git.
Check attachment names with paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rust.
3. Deploy the notes app
git add .
git commit -m "Add persistent notes and export processes"
git push d2o main
Dashboard: wait for notes-web to be running in its Deployments/Overview view.
CLI: run paas watch -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust. /health succeeds even before the schema exists;
/notes may return 503 until the next step.
4. Initialize the schema
Dashboard: notes-web → Console → select a running instance → Connect. Run:
/cnb/process/migrate
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust -- /cnb/process/migrate
Expect Schema ready. The command creates the notes table if missing, so it is safe to
repeat for this example. paas run does not relay the remote exit code: inspect the output
and verify /notes, rather than assuming a zero CLI exit means the schema succeeded.
5. Check persistence
Open the app URL, enter a sample note, and choose Save note. Refresh; the note remains. You can also test from your terminal:
curl -fsS -H "Content-Type: application/json" -d '{"body":"My first persistent note"}' "$APP_URL/notes"
curl -fsS "$APP_URL/notes"
Restart notes-web from its dashboard action or run paas restart -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust.
Wait for it to be running and fetch /notes again; the note must still be present.
An empty body returns HTTP 400. Leave Export notes for the next chapter.
For production, replace manual schema setup with versioned release-command migrations.
Next: Add a Redis export worker.
3. Add a Redis export worker
The web app queues a job and immediately returns its ID. A separate process reads the notes and generates JSON. This chapter stores that JSON in Redis; the next moves it to S3.
1. Create Redis
Dashboard: in rust add Database notes-queue, engine Redis, 1 GB storage;
wait until running. In notes-web → Config, attach it with variable name REDIS_URL.
Do not use the default DATABASE_URL, which already points at PostgreSQL.
CLI:
paas dbs create notes-queue --type redis --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-web notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e rust
2. Create the worker from the same code
Dashboard: add a Background Worker named notes-worker, Framework rust
(the same selection as the web app), Repository The environment’s shared repository,
Directory blank, Process type worker, one replica. A worker has no public HTTP endpoint.
Open its Config tab, set DATABASE_ENGINE=postgresql, and attach both notes-db as
DATABASE_URL and notes-queue as REDIS_URL.
CLI:
paas apps create notes-worker --worker --framework rust --shared-source --process worker -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=postgresql -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-worker notes-db -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-worker notes-queue --env-var REDIS_URL -o "$TUTORIAL_ORG" -p examples -e rust
The worker must have its own attachments; the web app’s configuration is not inherited. The process value is a named buildpack process, not a shell command. See the worker source for the job loop.
3. Push and check the worker
Trigger a shared build after both apps have their configuration:
git commit --allow-empty -m "Deploy web and export worker"
git push d2o main
Dashboard: wait for both apps to run, then open notes-worker → Logs and look for
Worker ready. CLI: paas watch -o "$TUTORIAL_ORG" -p examples -e rust follows both apps; then use
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust. The web app must still answer /health.
4. Export and recover a queued job
In the browser, click Export notes, wait for the download link, and open the JSON.
It must contain your saved note. Logs show Export ready: <id>.
To check queueing while the worker is unavailable, open its dashboard Overview and use − in the Replicas row to reach 0,
then confirm the stop, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust. Request an export; it stays queued.
Use + in the same row to restore replicas to 1 (or paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust); the same job should complete.
If the UI polling times out after 90 seconds, query /exports/<id> and download from
/exports/<id>/download once ready, or submit another export.
The worker uses Redis Streams with a consumer group. It acknowledges only after saving the result. A replacement worker claims unacknowledged jobs idle for 30 seconds; writes use the same export ID so a retried job overwrites the same result. Failures remain queued for retry and appear in worker logs. This is at-least-once delivery, not exactly-once delivery. Do not use this loop unchanged for side effects such as charging a payment.
Redis itself is not backed up by the platform. For essential jobs, retain enough information in the primary database to recreate or reconcile them. See durability and recovery and production preparation.
Next: Save exports in object storage.
4. Save exports in object storage
Keep notes in the database and move newly generated JSON exports into a private bucket. The web app streams downloads from S3; browser code never receives bucket credentials.
1. Create the bucket
Dashboard: in rust add Object Storage, name notes-exports, tier Capacity,
quota 1 GB. Leave it private and wait until provisioned. Capacity is suitable here
because exports can be recreated from notes. Use High availability for irreplaceable files.
CLI:
paas buckets create notes-exports --tier capacity --quota-gb 1 -o "$TUTORIAL_ORG" -p examples -e rust
paas buckets info -a notes-exports -o "$TUTORIAL_ORG" -p examples -e rust
Wait for available: true; keep public: false.
2. Attach the bucket to both apps
Dashboard: open each app’s Config tab, choose bucket notes-exports in the attachment
card, leave the credential prefix blank, and click Attach.
CLI:
paas apps attach notes-web notes-exports -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-worker notes-exports -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
The primary attachment injects S3_ENDPOINT, S3_BUCKET, S3_REGION,
AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY. The storage implementation
passes the endpoint and region explicitly and uses path-style S3 requests.
Do not add these credentials to frontend code, Git, or ordinary config variables.
Wait for both apps to restart with the new attachment before testing.
3. Generate a new export
Open the app and request a new export. Exports made before the bucket attachment still live in Redis, so their old download links no longer work while S3 mode is enabled. Download the new JSON and confirm your note appears.
Dashboard: open notes-exports → Files → exports/. The file named <job-id>.json
should be present. CLI:
paas buckets ls exports/ -a notes-exports -o "$TUTORIAL_ORG" -p examples -e rust
A direct anonymous object request should return 403; downloading through the app succeeds
because its server uses the attachment credentials. The bucket stays private, but the example
app itself has no user authentication. Do not enter confidential data.
4. Verify durability and diagnose failures
Restart notes-worker, submit another export, and check that it completes. Restart notes-web
and download the completed export again. No result depends on either app’s local filesystem.
If a job stays queued, check worker logs, both apps’ attachment lists, and bucket readiness.
After fixing a failed S3 write, the unacknowledged job retries after its 30-second idle interval.
Export files and Redis status records have no expiration in this teaching example. Delete unneeded files in the bucket’s Files tab, or clean up the entire isolated Environment when done.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MariaDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes, Redis queue, worker, and object-storage integration. The app selects the same SQL notes table, through its MariaDB driver. This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in rust create Database notes-mariadb, engine MariaDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mariadb --type mariadb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rust
paas dbs info notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rust
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mariadb using DATABASE_URL,
then set DATABASE_ENGINE=mariadb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rust
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-web notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-worker notes-mariadb -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=mariadb -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=mariadb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust. Create a new note identifying MariaDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Optional: persist notes in MongoDB
This chapter replaces the primary PostgreSQL attachment. It uses the same web routes,
Redis queue, worker, and object-storage integration. The app selects a notes collection with a unique id index, through its MongoDB driver.
This is a fresh dataset, not an automatic migration of your PostgreSQL notes.
1. Create the alternative database
Dashboard: in rust create Database notes-mongodb, engine MongoDB, 1 GB storage.
Wait until running. CLI:
paas dbs create notes-mongodb --type mongodb --storage-gb 1 -o "$TUTORIAL_ORG" -p examples -e rust
paas dbs info notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rust
2. Pause traffic and change both apps
Finish outstanding exports, then stop using the sample app while changing stores.
On the worker’s Overview, use − in Replicas to reach zero and confirm, or run paas scale 0 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust.
Dashboard: for both notes-web and notes-worker, open Config and detach only the
current primary database (DATABASE_URL). Attach notes-mongodb using DATABASE_URL,
then set DATABASE_ENGINE=mongodb. Keep Redis and bucket attachments.
CLI: list each app’s attachments and copy the ID for its primary database:
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
Replace the two placeholders below with the attachment IDs, which differ per app and are not the database resource ID:
paas apps detach notes-web <web-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rust
paas apps detach notes-worker <worker-primary-attachment-id> -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-web notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attach notes-worker notes-mongodb -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=mongodb -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas config set DATABASE_ENGINE=mongodb -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
Do not keep a plain DATABASE_URL variable that could override the injected attachment.
Changing attachments/config rolls out the app automatically; wait until the web process is running.
See attachments and configuration precedence.
3. Initialize and verify
Dashboard: run /cnb/process/migrate in notes-web’s Console.
CLI:
paas run -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust -- /cnb/process/migrate
Expect Schema ready. Restore the worker to one replica using + on Overview or with
paas scale 1 -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust. Create a new note identifying MongoDB, restart the web app,
and verify it remains. Generate a new export and confirm the new note appears in the JSON.
Previously generated export files are snapshots of the old store; they do not change.
To return to PostgreSQL, repeat the attachment change for notes-db and set
DATABASE_ENGINE=postgresql on both apps. Your earlier PostgreSQL notes remain there
until you explicitly delete that database. Each engine can be followed independently;
you do not have to finish the other optional chapter first.
Continue with MariaDB or MongoDB if you want to compare primary stores. Each is optional and starts a separate dataset. Then prepare the app for production or clean up the tutorial resources.
Rust and Axum source walkthrough
The runnable source below is included from the same files packaged in the notes download. Lockfiles, configuration, and the shared browser UI are included in the download.
Database
A notes request reaches this adapter after input validation. DATABASE_ENGINE chooses
PostgreSQL, MariaDB, or MongoDB; DATABASE_URL comes from the app’s attachment. SQL uses
parameters rather than interpolating note text, and MongoDB uses driver operations.
The initialization process creates a table or unique index. Records live in the database,
so replacing a web instance leaves them available to its replacement.
#![allow(unused)]
fn main() {
enum Database {
Postgres(PgPool),
Maria(MySqlPool),
Mongo(Collection<Document>),
}
static DATABASE: OnceCell<Database> = OnceCell::const_new();
async fn database() -> Result<&'static Database> {
DATABASE
.get_or_try_init(|| async {
let url = std::env::var("DATABASE_URL").context("Attach a database first")?;
Ok(match env("DATABASE_ENGINE", "postgresql").as_str() {
"postgresql" => Database::Postgres(
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await?,
),
"mariadb" => Database::Maria(
sqlx::mysql::MySqlPoolOptions::new()
.max_connections(5)
.connect(&url)
.await?,
),
"mongodb" => {
let client = mongodb::Client::with_uri_str(&url).await?;
Database::Mongo(
client
.default_database()
.context("DATABASE_URL needs a database name")?
.collection("notes"),
)
}
_ => anyhow::bail!("Unsupported DATABASE_ENGINE"),
})
})
.await
}
pub async fn migrate() -> Result<()> {
let query = "CREATE TABLE IF NOT EXISTS notes (id VARCHAR(36) PRIMARY KEY, body TEXT NOT NULL)";
match database().await? {
Database::Postgres(db) => {
sqlx::query(query).execute(db).await?;
}
Database::Maria(db) => {
sqlx::query(query).execute(db).await?;
}
Database::Mongo(db) => {
db.create_index(
IndexModel::builder()
.keys(doc! {"id": 1})
.options(IndexOptions::builder().unique(true).build())
.build(),
)
.await?;
}
}
Ok(())
}
pub async fn notes() -> Result<Vec<Note>> {
Ok(match database().await? {
Database::Postgres(db) => sqlx::query("SELECT id, body FROM notes ORDER BY id")
.fetch_all(db)
.await?
.into_iter()
.map(|r| Note {
id: r.get("id"),
body: r.get("body"),
})
.collect(),
Database::Maria(db) => sqlx::query("SELECT id, body FROM notes ORDER BY id")
.fetch_all(db)
.await?
.into_iter()
.map(|r| Note {
id: r.get("id"),
body: r.get("body"),
})
.collect(),
Database::Mongo(db) => db
.find(doc! {})
.sort(doc! {"id":1})
.await?
.try_collect::<Vec<_>>()
.await?
.into_iter()
.map(|d| {
Ok(Note {
id: d.get_str("id")?.into(),
body: d.get_str("body")?.into(),
})
})
.collect::<Result<Vec<_>>>()?,
})
}
pub async fn add_note(body: String) -> Result<Note> {
let note = Note {
id: uuid::Uuid::new_v4().to_string(),
body,
};
match database().await? {
Database::Postgres(db) => {
sqlx::query("INSERT INTO notes (id, body) VALUES ($1, $2)")
.bind(¬e.id)
.bind(¬e.body)
.execute(db)
.await?;
}
Database::Maria(db) => {
sqlx::query("INSERT INTO notes (id, body) VALUES (?, ?)")
.bind(¬e.id)
.bind(¬e.body)
.execute(db)
.await?;
}
Database::Mongo(db) => {
db.insert_one(doc! {"id":¬e.id,"body":¬e.body})
.await?;
}
}
Ok(note)
}
}
Worker
Creating an export enqueues an ID and returns immediately. This loop reads the notes, writes an export under that ID, records its status, and acknowledges the stream entry. It also reclaims jobs abandoned before acknowledgement. Retrying writes the same export key, so interruption does not create an additional export file; the snapshot can reflect newer notes if they changed before the retry. See worker recovery.
#![allow(unused)]
fn main() {
async fn process(q: &mut Queue, message: Message) -> Result<()> {
let (message_id, fields) = message;
let id = fields
.chunks_exact(2)
.find(|pair| pair[0] == "id")
.map(|pair| pair[1].clone())
.unwrap_or_default();
if uuid::Uuid::parse_str(&id).is_ok() {
if status(q, &id).await?.as_deref() != Some("ready") {
save_export(q, &id, serde_json::to_vec_pretty(¬es().await?)?).await?;
redis::cmd("SET")
.arg(format!("export:{id}"))
.arg("ready")
.query_async::<()>(q)
.await?;
}
println!("Export ready: {id}");
}
redis::cmd("XACK")
.arg("exports")
.arg("workers")
.arg(&message_id)
.query_async::<i64>(q)
.await?;
redis::cmd("XDEL")
.arg("exports")
.arg(message_id)
.query_async::<i64>(q)
.await?;
Ok(())
}
async fn next(q: &mut Queue, consumer: &str) -> Result<()> {
let (_, mut messages, _): (String, Vec<Message>, Vec<String>) = redis::cmd("XAUTOCLAIM")
.arg("exports")
.arg("workers")
.arg(consumer)
.arg(30000)
.arg("0-0")
.arg("COUNT")
.arg(1)
.query_async(q)
.await?;
if messages.is_empty() {
let reply: ReadReply = redis::cmd("XREADGROUP")
.arg("GROUP")
.arg("workers")
.arg(consumer)
.arg("COUNT")
.arg(1)
.arg("BLOCK")
.arg(5000)
.arg("STREAMS")
.arg("exports")
.arg(">")
.query_async(q)
.await?;
messages = reply
.and_then(|mut streams| streams.pop().map(|(_, messages)| messages))
.unwrap_or_default();
}
for message in messages {
process(q, message).await?;
}
Ok(())
}
async fn run() -> Result<()> {
let mut q = queue().await?;
let group = redis::cmd("XGROUP")
.arg("CREATE")
.arg("exports")
.arg("workers")
.arg("0")
.arg("MKSTREAM")
.query_async::<()>(&mut q)
.await;
if let Err(error) = group {
if !error.to_string().contains("BUSYGROUP") {
return Err(error.into());
}
}
let consumer = uuid::Uuid::new_v4().to_string();
println!("Worker ready");
let stop = shutdown();
tokio::pin!(stop);
loop {
tokio::select! {
_ = &mut stop => return Ok(()),
result = next(&mut q, &consumer) => {
if result.is_err() {
eprintln!("Export failed; unacknowledged jobs retry after 30 seconds. Check attachments and schema.");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Ok(connection) = queue().await { q = connection; }
}
}
}
}
}
}
Object storage
Without S3 configuration, exports are stored in Redis for the worker checkpoint. With
an attached bucket, the worker writes JSON to exports/<id>.json and the web process
reads that object for downloads. Both processes need their own bucket attachment.
The client uses the supplied endpoint and region with path-style requests. Credentials
stay on the server; a private bucket does not add authentication to the example’s public download route.
#![allow(unused)]
fn main() {
async fn storage() -> Result<aws_sdk_s3::Client> {
let shared = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new(std::env::var("S3_REGION")?))
.load()
.await;
let config = aws_sdk_s3::config::Builder::from(&shared)
.endpoint_url(std::env::var("S3_ENDPOINT")?)
.force_path_style(true)
.build();
Ok(aws_sdk_s3::Client::from_conf(config))
}
pub async fn save_export(q: &mut Queue, id: &str, data: Vec<u8>) -> Result<()> {
if let Ok(bucket) = std::env::var("S3_BUCKET") {
storage()
.await?
.put_object()
.bucket(bucket)
.key(format!("exports/{id}.json"))
.content_type("application/json")
.body(aws_sdk_s3::primitives::ByteStream::from(data))
.send()
.await?;
} else {
redis::cmd("SET")
.arg(format!("export:{id}:data"))
.arg(data)
.query_async::<()>(q)
.await?;
}
Ok(())
}
pub async fn load_export(q: &mut Queue, id: &str) -> Result<Vec<u8>> {
if let Ok(bucket) = std::env::var("S3_BUCKET") {
let output = storage()
.await?
.get_object()
.bucket(bucket)
.key(format!("exports/{id}.json"))
.send()
.await?;
Ok(output.body.collect().await?.into_bytes().to_vec())
} else {
Ok(redis::cmd("GET")
.arg(format!("export:{id}:data"))
.query_async(q)
.await?)
}
}
}
HTTP routes
use axum::{
Json, Router,
extract::Path,
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use d2o_notes::*;
use serde_json::{Value, json};
struct Unavailable;
impl From<anyhow::Error> for Unavailable {
fn from(_: anyhow::Error) -> Self {
Self
}
}
impl IntoResponse for Unavailable {
fn into_response(self) -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error":"Check attachments, schema, and service availability"})),
)
.into_response()
}
}
async fn list() -> Result<Json<Vec<Note>>, Unavailable> {
Ok(Json(notes().await?))
}
async fn create(Json(input): Json<Value>) -> Result<Response, Unavailable> {
let Some(body) = input
.get("body")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty() && s.chars().count() <= 2000)
else {
return Ok((
StatusCode::BAD_REQUEST,
Json(json!({"error":"Enter a note of 1–2000 characters"})),
)
.into_response());
};
Ok((StatusCode::CREATED, Json(add_note(body.into()).await?)).into_response())
}
async fn export() -> Result<Response, Unavailable> {
Ok((StatusCode::ACCEPTED, Json(json!({"id":enqueue().await?}))).into_response())
}
async fn export_status(Path(id): Path<String>) -> Result<Response, Unavailable> {
let state = status(&mut queue().await?, &id).await?;
Ok((
if state.is_some() {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
},
Json(json!({"status":state.unwrap_or("unknown".into())})),
)
.into_response())
}
async fn download(Path(id): Path<String>) -> Result<Response, Unavailable> {
let mut q = queue().await?;
if uuid::Uuid::parse_str(&id).is_err() || status(&mut q, &id).await?.as_deref() != Some("ready")
{
return Ok((
StatusCode::NOT_FOUND,
Json(json!({"error":"Export not ready"})),
)
.into_response());
}
Ok((
[
(header::CONTENT_TYPE, "application/json"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=notes.json",
),
],
load_export(&mut q, &id).await?,
)
.into_response())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let app = Router::new()
.route(
"/",
get(|| async { Html(include_str!("../public/index.html")) }),
)
.route("/health", get(|| async { "ok\n" }))
.route("/notes", get(list).post(create))
.route("/exports", post(export))
.route("/exports/{id}", get(export_status))
.route("/exports/{id}/download", get(download))
.layer(axum::extract::DefaultBodyLimit::max(16384));
let listener =
tokio::net::TcpListener::bind(format!("0.0.0.0:{}", env("PORT", "3000"))).await?;
println!("Web ready");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown())
.await?;
Ok(())
}
Review production preparation before adapting these handlers to real users.
Troubleshoot and clean up Rust and Axum
Diagnose a failed checkpoint
Both Cargo.toml and Cargo.lock must be at the source root. Commit rust-toolchain.toml. Set Process type web or worker to match the compiled binary names; /cnb/process/migrate selects the migration binary inside the image.
| Symptom | Check |
|---|---|
| No build starts | The Environment uses release source Build, the pushed branch is main, and both apps use the shared repository root. Review framework availability. |
| Build fails detection | Check the manifest and lockfiles in the committed source, not just local files. See the hello chapter’s file list. |
| Health check fails | Port is 3000, binding is 0.0.0.0, path is /health, and the web app selects web. |
| Notes returns 503 | Primary attachment is DATABASE_URL, engine matches its type, database is running, and the schema command printed Schema ready. |
| Export stays queued | Worker selects worker, has its own database and REDIS_URL attachments, and has one running replica. A recovered pending job waits up to 30 seconds. |
| Download fails after adding S3 | Attach the bucket to both apps with no prefix, wait for their restart, and create a new export. Old Redis-only exports have no S3 object. |
| Forbidden operation | Confirm your project capabilities for deploying, databases, storage, or secrets; ask the project administrator to grant the needed access. |
Dashboard: inspect the app’s Deployments, Logs, and Config tabs. The Process field is also editable under Settings; clearing it restores the buildpack’s default process.
CLI:
paas ps -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas logs -a notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas logs -a notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-web -o "$TUTORIAL_ORG" -p examples -e rust
paas apps attachments notes-worker -o "$TUTORIAL_ORG" -p examples -e rust
Do not print connection strings or credentials into logs. The example’s error responses identify the failing checkpoint without exposing a password.
Before deleting the example, review Take an Example to Production if you plan to adapt it. For platform-wide symptoms, see Troubleshooting & FAQ.
Clean up
Keep anything you want to retain by downloading exports first. Deleting this tutorial Environment permanently removes its apps and data resources; it is not a way to pause them.
Dashboard: open Project Examples → Environment rust → Settings → Delete Environment,
then confirm its name. Make sure this Environment contains only the tutorial resources.
CLI: review its resources, then confirm deletion:
paas apps list -o "$TUTORIAL_ORG" -p examples -e rust
paas dbs list -o "$TUTORIAL_ORG" -p examples -e rust
paas envs delete rust -o "$TUTORIAL_ORG" -p examples --confirm rust
This removes the web/worker, primary and optional databases, Redis, and tutorial bucket
created in this Environment. If you used local services, run docker compose down -v
from the example folder to discard that separate local dataset.
Static HTML with NGINX and a public asset
Build a small site from HTML and an NGINX configuration, then display a public image from object storage. Complete Before you start first. For an existing frontend project, use Deploying Static Sites. This path uses no language runtime, database, or background worker.
1. Download and inspect the site
Download the static site ZIP, then run:
unzip static-site.zip
cd static-site
The HTML lives in public/index.html:
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hello from d2o</title><h1>Hello from Static Files on d2o!</h1>
<p>Edit this page and push again to deploy an update.</p>
<!-- In the object storage chapter, add an img with your public asset URL. -->
</html>
The buildpack detects the root nginx.conf. Its {{port}} template reads the app’s
runtime PORT, while its web root points at the packaged public directory. This
example deliberately adds /health; a basic static site can instead use the guide’s
default configuration and health path /:
worker_processes 1;
daemon off;
error_log stderr;
events { worker_connections 1024; }
http {
types {
text/html html;
text/css css;
application/javascript js;
application/json json;
image/png png;
image/jpeg jpg jpeg;
image/svg+xml svg;
image/x-icon ico;
}
default_type application/octet-stream;
access_log /dev/stdout;
client_body_temp_path /tmp/client_body;
server {
listen {{port}};
root /workspace/public;
location = /health { default_type text/plain; return 200 "ok\n"; }
location / { try_files $uri $uri/ =404; }
}
}
To preview just the HTML locally, run python3 -m http.server 3000 --directory public
and open http://localhost:3000. This preview does not test NGINX’s template or /health;
the deployed image does. Stop it with Ctrl+C before continuing.
2. Create the Environment and app
Dashboard: in Project Examples, create Environment static, type Development,
branch main, Automatic deployment on, release source Build. Add a Web Application named
site, Framework Static Files, Repository The environment’s shared repository,
Directory blank, Process type blank (use the buildpack default), Port 3000, and
Health Check Path /health.
CLI:
paas envs create static -o "$TUTORIAL_ORG" -p examples --type development --branch main --release-source build
paas apps create site -o "$TUTORIAL_ORG" -p examples -e static --framework static --shared-source --port 3000 --health-check-path /health
3. Push and verify
git init -b main
git add .
git commit -m "Add static site"
Dashboard path: copy the shared remote command from the Environment’s Git remote panel into this directory, then run its push command. Wait for the app’s successful Deployment and open its public URL.
CLI path:
paas git remote -o "$TUTORIAL_ORG" -p examples -e static
git push d2o main
paas watch -o "$TUTORIAL_ORG" -p examples -e static -a site
Read the public URL in the dashboard, or the url field from
paas apps info site --json -o "$TUTORIAL_ORG" -p examples -e static.
Open the site, then append /health to its URL; expect ok. A 404 on /health
suggests the root NGINX configuration was not packaged or the wrong source directory was selected.
4. Create and upload a public asset
Choose a small image that is safe for anyone to view, saved locally as banner.png.
Use a separate bucket containing only public demo assets.
Dashboard: in static, add Object Storage site-assets, tier Capacity, quota 1 GB.
Wait for provisioning. In its Files tab upload banner.png. On Overview choose
Make public and confirm that every object will be publicly readable. Back in Files,
use Copy URL on the image.
CLI: create the private bucket and wait for available: true:
paas buckets create site-assets -o "$TUTORIAL_ORG" -p examples -e static --tier capacity --quota-gb 1
paas buckets info -o "$TUTORIAL_ORG" -p examples -e static -a site-assets
Upload with the CLI, then explicitly make this demo bucket public:
paas buckets put banner.png banner.png --content-type image/png -o "$TUTORIAL_ORG" -p examples -e static -a site-assets
paas buckets visibility public -o "$TUTORIAL_ORG" -p examples -e static -a site-assets
paas buckets url -o "$TUTORIAL_ORG" -p examples -e static -a site-assets
Append /banner.png to the printed public bucket URL. For other S3 tools, see
using the AWS CLI.
5. Display the asset
Add this inside public/index.html, replacing the URL with the image’s actual public URL:
<img src="https://your-bucket-host/banner.png" alt="Tutorial banner" width="640">
git add public/index.html
git commit -m "Show public bucket asset"
git push d2o main
Wait for the new Deployment, refresh the site, and confirm the image loads in a private browser window. DevTools should show only the public object URL, with no S3 credentials. There is no need to attach the bucket to the static app. Browser JavaScript must never contain database connection strings or private bucket keys. Use a server-side tutorial when you need authenticated uploads, private downloads, or database queries.
6. Troubleshoot and clean up
If the build cannot detect a web server, check that nginx.conf is at the source root
and public/index.html is committed. If the image returns 403, confirm the object key
and that this demo bucket is public. If the app fails health checks, confirm Port 3000
and the {{port}} template. View build status in Deployments and runtime errors in Logs,
or use paas logs -o "$TUTORIAL_ORG" -p examples -e static -a site.
When done, delete the isolated static Environment in the dashboard’s Environment Settings,
or run the command below after confirming it contains only this tutorial. This permanently
removes both the site and its bucket data:
paas envs delete static -o "$TUTORIAL_ORG" -p examples --confirm static
Next: Take an Example to Production, including public asset handling and storage recovery.
Take an Example to Production
The tutorials teach deployment, persistence, workers, and object storage. Their notes UI has no authentication, and exports and job records have no retention policy. Complete these steps before using an example with real users or sensitive data.
1. Establish application boundaries
Add authentication and authorization to notes, exports, and downloads. Validate inputs and upload sizes, and add appropriate request limits. Keep private bucket credentials on the server. Decide how long to keep notes, export files, and queue status records, then implement deletion for each storage system.
Read Designing Apps for d2o for stateless instances, temporary files, shared sessions, startup validation, logging, connection pools, graceful shutdown, and safe retries.
2. Create separate deployment environments
Keep tutorial data in its disposable Development Environment. Create the real app’s development, staging, and production Environments separately, each with its own databases, buckets, and secrets. Link the build Environment to your GitHub or GitLab repository, enable Wait for CI, and use promotion to move a tested build through staging into protected production.
Review the provider’s Wait for CI behavior, including what happens when a commit has no checks. If you push from a CI job instead, follow Deploying from CI, including deployment verification.
3. Version settings and migrations
Move process selection, health checks, and the release command into d2o-paas.yaml. The tutorials run schema initialization manually so you can inspect the result. For production, use versioned, repeatable migrations as a release command, rather than starting a migration independently in every replica.
The examples’ migrate process illustrates initialization; it is not a complete schema-evolution system. Adopt your framework’s migration tooling before changing an existing schema. Test migrations while the previous version is still serving, and test a code rollback against the updated database. Follow the safe migration sequence.
4. Choose durability and recovery
Review what survives failures and what is backed up. Use attachments instead of copying database or S3 credentials. Redis-only exports and queue status are disposable: if exports must remain discoverable after Redis loss, store their job metadata in the primary database and add recovery/reconciliation logic.
Choose a suitable object-storage tier and keep independent copies where required. Practise the restore procedure before an incident. High availability does not recover accidental deletion, and deleting a database also deletes its platform backups.
5. Validate availability and operations
Add a fast health check and graceful shutdown, then test replacing both web and worker instances. Use at least two web instances for production availability unless the application requires a singleton; size connection pools and memory for the total replica count. Verify worker retries cannot duplicate business effects.
Follow deploys to Live, inspect logs and metrics, and practise rollback in staging. Finish the production checklist.
6. Remove disposable resources
Download anything you want to keep before deleting a tutorial Environment. Use the language tutorial’s Troubleshooting & Cleanup chapter to review and remove its resources. Deleting an Environment permanently removes its apps and data; stopping local Docker Compose services does not delete hosted resources.
Return to the language tutorials to compare another framework.
Designing Apps for d2o
Build each app so that replacing or adding an instance does not lose data or interrupt work. These principles apply to every supported framework, whether you deploy a web app, a worker, or a static frontend.
Keep durable state outside instances
An instance’s filesystem and memory belong to that instance. Files can disappear when it restarts or is replaced, and another replica cannot see them. Store structured records in a database and uploads, generated exports, and other durable files in object storage. A local SQLite file, upload directory, or in-memory session store is unsuitable as the only copy of production data.
Temporary files are fine for bounded scratch work: unpacking an upload, transforming an image, or preparing an export. Clean them up, limit their size, and copy any result you need to keep into durable storage before reporting success. A singleton app still has an ephemeral filesystem.
Keep sessions and shared coordination outside the web process, or use an appropriate signed-cookie session design. Treat Redis caches as rebuildable. The platform does not back up Redis: a queue surviving a worker restart is different from surviving loss of Redis itself. For essential jobs, retain enough information in a backed-up database to recreate or reconcile them. See the durability comparison.
Read configuration at runtime
Read service addresses, feature flags, and credentials from environment variables. Use secret references for credentials and attachments for managed services. Keep each Environment’s settings separate and commit neither credentials nor local .env files.
Validate required settings at startup. A missing secret reference can leave its variable absent; it does not necessarily prevent the process from starting. Exit with a useful message naming the missing setting, without printing its value.
Build-time settings and runtime settings serve different purposes. Version selection and frontend compilation happen during the build; database connections and application secrets are used by the running process. Do not require a live production database to compile an image. See configuration and build/runtime network access.
Anything embedded in browser JavaScript, HTML, source maps, or other static assets is public, even if the build tool calls its input an environment variable. Keep private API keys and database credentials in a server-side app. A runtime secret cannot make an already compiled frontend asset private.
Listen on the configured port
Web apps must listen on 0.0.0.0 and the injected PORT, rather than a fixed port or loopback-only address. Workers have no public URL; a worker may expose an HTTP health endpoint on its configured port or use a health-check command.
Give web apps a fast, unauthenticated health endpoint that reports successful startup. Avoid restarting every instance because a shared dependency briefly fails: use request timeouts and bounded retries for dependency failures. See health checks and their defaults.
Shut down gracefully and make retries safe
When replacing an instance, d2o sends SIGTERM and allows 30 seconds before force-stopping it. Allow a short drain period while routing catches up, then stop accepting new work, finish in-flight requests or jobs, and exit. Configure your framework’s shutdown handling and keep work within that window.
A worker can stop after making a change but before acknowledging its job. Design jobs to be safe to retry: use stable job IDs, unique database constraints, and repeatable writes. Acknowledge only after the result has been saved. Long jobs should checkpoint or be split into smaller pieces. Do not assume a singleton or a queue provides exactly-once execution.
Share connections and handle dependency failures
Use bounded connection pools per instance. Scaling from two instances to ten also multiplies their potential database connections; allow capacity for workers and release commands too. Set connection and request timeouts, reconnect after failures, and retry transient errors with backoff. Do not blindly retry non-repeatable operations such as charging a customer.
Send logs to stdout and stderr
Print application events and useful errors to standard output or standard error; d2o collects them across instances. Do not depend on log files inside the container. Include request or job identifiers when useful, but redact passwords, tokens, connection strings, and sensitive request data. See Logs.
Prepare for production
Keep process selection, port, health checks, and release commands in versioned app settings. Use backward-compatible database migrations because old and new code overlap during deployment, and rolling back code does not roll back data.
Continue with Take an Example to Production or the production checklist.
Organizations, Projects & Environments
Everything you run on d2o PaaS lives in a three-level hierarchy:
Organization (acme)
└── Project (shop)
├── Environment (production)
│ ├── Web Application (web)
│ └── Database (shop-db)
└── Environment (staging)
- An Organization owns everything: projects, members, secrets, and your plan’s usage limits.
- A Project groups related environments — typically one project per application or service.
- An Environment is a deployment target (for example
productionorstaging) that holds the actual Resources: Web Applications, Background Workers, Databases, and Object Storage.
URLs follow the same shape: the shop project in the acme organization lives at /orgs/acme/projects/shop in the dashboard.
Personal and team organizations
The platform is invitation-only. When you accept an invitation and sign in for the first time, a personal organization is created for you automatically — it is marked Personal in the organization switcher and works like any other organization, except that it cannot be deleted and its ownership cannot be transferred.
For shared work, create a team organization: from the dashboard, choose Create Organization and give it a name and a URL slug (lowercase letters, numbers, and hyphens; the slug appears in URLs like /orgs/acme). Every organization has exactly one owner; the owner and admins can invite members and manage projects. See Access Control for what each role can do.
Only the owner can delete a team organization. Deleting it permanently removes every project, environment, and resource it contains, and asks you to type the organization name to confirm.
Projects
Create a project from the organization page with New Project. A project is mostly a grouping: it holds environments, project-scoped secrets, and the permission grants that control who can work in it. There is nothing to deploy at the project level — that happens in environments.
Deleting a project permanently removes all of its environments and everything in them — apps, databases, buckets and their contents, and secrets — protected environments included. Because of that, a project that contains a protected environment can only be deleted by an Owner or Admin of the organization; anyone else with permission to manage the project can delete projects without protected environments.
Environments
Create an environment from a project’s Environments page with New Environment. Names use lowercase letters, numbers, and hyphens (for example production or staging-eu). The name is fixed at creation, and the type cannot be changed from the dashboard or CLI. Automatic deployment, Protected and Release source can be changed later in the environment’s Settings page (switching to promote-only needs every app to have a built image first); the environment also records a Git branch setting (paas envs update --branch), though apps currently build from the main branch of their platform-hosted repository regardless.
Environment types
Each environment is one of three types: Development, Staging, or Production. The type is how the dashboard groups your environments, and it is how your plan accounts for capacity — limits are tracked per environment type, and some plans may not include every type. See Plans, Limits & Usage.
Git branch and Automatic deployment
Each environment records a Git branch setting (main by default). Builds follow each app’s own source configuration: apps deployed with git push build from the main branch of their platform-hosted repository. Automatic deployment (on by default) releases each new build automatically as soon as it finishes. Changes to an environment’s resources and secrets always roll out, regardless of this setting. With Automatic deployment off, new builds still run but wait as Built in the deployment history instead of going live — useful for production environments where you want to release on your own schedule. See How Builds & Deployments Work. Apps linked to a GitHub or GitLab repository deploy from the branch chosen on the link — see Deploy from GitHub or GitLab.
Release source
Each Environment either builds from source (the default: pushes to its branch are built and deployed) or is promote-only (it never builds; apps run whatever image was last promoted into it from the stage upstream). Together the Environments of a Project form a pipeline, ordered development → staging → production. See Pipelines, Promotion & Rollback.
Protected environments
Mark an environment Protected to guard it against accidental destructive actions. A protected environment cannot be deleted at all — attempting it returns an error until you remove protection in the environment’s settings, which only an organization Owner or Admin can do. Promotions into a protected environment ask for confirmation, and only an organization Owner or Admin can delete a project that contains one. Use it for production.
Deleting an unprotected environment permanently removes the environment and all of its resources, and asks you to type the environment name to confirm. This cannot be undone.
High availability
High availability is set per resource, not per environment: run several instances of an app with its scaling settings — see Scaling & Autoscaling — and give a PostgreSQL database a standby copy with high availability.
Visualizing an environment
An environment’s Resources section opens by default in Topology view: a diagram of every resource in the environment — Web Applications, Background Workers, Databases, and Object Storage — as nodes colored by their current status. When the environment has at least one Web Application, a single public entry point appears with a line to each of them, showing how many are running. A line from an app or worker to a database or bucket appears when it’s attached. A line between two apps appears when one app’s plain environment variable names the other — its name (API_HOST=api) or its public address — labelled with the variable’s name; secrets are never read for this. Resources that share an environment but aren’t wired together have no line between them. It’s a quick way to see everything running in an environment and how it’s actually connected. Switch to Cards or List with the toggle above the diagram for a plainer view of the same resources.
Working from the CLI
The paas CLI mirrors the hierarchy with the -o, -p, and -e context flags:
paas orgs list
paas projects list -o acme
paas envs list -o acme -p shop
paas orgs set-default and paas projects set-default save a default context so you can omit the flags. Each group also has info, create, update, and delete subcommands — run any of them with --help for the exact flags, for example paas envs create --help.
Next steps
- Resources — what you can run inside an environment
- How Builds & Deployments Work — branches, builds, and Automatic deployment in detail
- Inviting People — bring your team into an organization
- Access Control — roles, groups, and project permissions
Favourite organizations
Use the star beside an organization on the dashboard or in the organization switcher to add it to Favourites. Favourites appear above the remaining organizations and are saved to your account across devices. Search includes both sections. Favouriting an organization does not grant access to it.
Resources
Resources are the things you run and store on the platform. Every resource lives inside an Environment (see Organizations, Projects & Environments), and there are four types:
| Type | What it is |
|---|---|
| Web Application | HTTP service with a web endpoint |
| Background Worker | Long-running process without HTTP |
| Database | Managed database instance |
| Object Storage | S3-compatible bucket for files and images |
Every resource has a name made of lowercase letters, numbers, and hyphens. Pick it carefully — for apps it is used in URLs and DNS.
Web Application
A Web Application is a service that speaks HTTP. You choose a framework (Ruby on Rails, Node.js, Python, Go, Rust, or Static Files — see Supported Frameworks for caveats), a listening port, and a health-check path; the platform builds your code with buildpacks and runs it.
The platform hosts a Git repository for your app. After creating it you get a remote to push to — pushing to the main branch triggers a build and deployment. Push to it directly, or link a GitHub or GitLab repository so pushes there deploy automatically — see Deploy from GitHub or GitLab.
You control how many instances run, and CPU and memory limits per instance. Guides:
Background Worker
A Background Worker is a long-running process without HTTP — queue consumers, schedulers, bots. It builds and deploys exactly like a Web Application (same frameworks, same git-push flow, same scaling, logs, and console access); it just has no public URL, and its optional health check can be a command run inside the instance as well as an HTTP path.
Database
A Database is a managed database instance. Four engines are available: PostgreSQL, Redis, MariaDB, and MongoDB.
You don’t manage credentials by hand: attach a database to an app and the platform injects a connection-string environment variable (named DATABASE_URL by default) into that app.
PostgreSQL and MariaDB databases are backed up automatically; restoring from a backup requires contacting support.
Object Storage
An Object Storage resource is an S3-compatible bucket for files and images, with an optional size quota (in GB). The High availability storage tier may not yet be offered in Production-type Environments on your installation; the default Capacity tier is. Each bucket gets its own public URL, assigned shortly after creation; making the bucket public lets anyone read its objects at that URL, while a private bucket refuses public reads. The CLI can print S3-compatible credentials for use with standard S3 tooling (paas buckets creds).
Creating resources
In the dashboard, open the Environment (for example acme → shop → production) and choose Add Resource. The form asks only for what the selected type needs: source and sizing for apps, an engine for databases, an optional quota for buckets.
From the CLI, apps and databases have their own command groups:
paas apps list -o acme -p shop -e production
paas apps create --help # flags for creating a web app or worker
paas dbs create --help # flags for creating a database
paas buckets works with the Object Storage resource selected with -a (-a media) — see the Object Storage Buckets guide for the full flow.
Next steps
- Quickstart: Deploy Your First App
- How Builds & Deployments Work
- Secrets & Configuration
- Plans, Limits & Usage
How Builds & Deployments Work
Every Web Application and Background Worker on d2o PaaS goes through the same pipeline: your source code is turned into a runnable image by Cloud Native Buildpacks, and that image is then deployed into the Environment. This page explains each stage and the knobs you control.
The pipeline at a glance
- Source — new code arrives on the platform-hosted repository’s
main— pushed by you, your CI, or a linked GitHub/GitLab branch. - Build — buildpacks build a version of your app from that commit using the framework you declared for the app. No Dockerfile is required.
- Release — if the app has a release command, it runs once against the new version before anything else starts.
- Deploy — new instances start and must pass their health check before they take traffic. With Automatic deployment on (the default) this happens automatically; with it off, the build waits until you deploy it yourself. A version that never becomes healthy is rolled back automatically. See Deployments, Health Checks & Rollback.
Each deployment is pinned to the exact image produced from your commit — the platform records the image by its digest, so what runs never silently drifts from what was built.
Where your code comes from
The platform hosts a git repository for every app — or one per Environment that several apps share. Code reaches it in one of three ways: you push to it (below), your CI pushes with a deployment token, or you link a GitHub or GitLab branch and every push there is deployed. Pointing an app at an arbitrary git URL isn’t supported.
The app’s Deployments tab in the dashboard shows ready-to-paste push
commands (the remote URL embeds an access token), or the CLI configures a
paas remote for you:
paas git remote -o acme -p shop -e production -a web
git push paas main
The CLI’s paas remote uses SSH by default — register a key first with
paas keys add — or pass --https for an HTTPS remote whose push token is
kept by git’s credential helper.
See Deploying with Git Push.
Apps always build from the platform-hosted repository’s main, wherever the
code came from.
Apps that share an Environment’s repository and have the same source directory and framework share one build: the image is built once per push and deployed to each of them, each running its own process.
Builds and frameworks
Builds use Cloud Native Buildpacks: the platform builds an image straight from your source using the buildpack for your app’s framework. Every Web Application and Background Worker declares its framework when you create it — it must be one of the supported frameworks, and an app without one is rejected up front rather than deployed broken. An app whose build produces more than one process type can choose which one to start (the app’s Process setting).
Build and runtime connectivity differ; see Network access.
While a build is running, the app’s status and log views tell you the image is still building; a failed build shows as Failed in the deployment history, and the Organization’s owner gets an email about it (once per failure, when email is configured on the installation), so a broken push never goes unnoticed.
Network access
| Process | Can reach | Cannot reach |
|---|---|---|
| Build | Public internet, including registries with public endpoints | Environment databases, platform internals, private network addresses |
| Running app | Public internet and databases/buckets in its own Environment | Other Environments’ private resources, platform internals, private external networks |
Blocked private ranges include 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, and 100.64.0.0/10. An authenticated registry still needs a publicly reachable address for a build. Vendor a dependency if it cannot be served from a reachable endpoint; do not query a live database during compilation.
Use attachments for managed service addresses and credentials. Do not construct internal database hostnames yourself or expect a local machine to reach them. Run diagnostics from the app’s console. For a public web service, use its displayed HTTPS URL or custom domain, not a guessed hostname. Workers have no public URL.
An unreachable dependency is different from an invalid credential: first check the address and Environment, then connection timeouts and authentication. Applications should use bounded timeouts, reconnect, and back off when retrying transient failures; see Designing Apps for d2o.
Automatic deployment
Automatic deployment is a per-Environment setting, on by default. While it’s on, every successful build of new code is deployed automatically.
Turn it off when you want to control exactly when code ships (a production Environment, say). The platform still builds every push — the build simply waits, showing as Built (waiting to be deployed) in the deployment history, until you deploy it.
You set Automatic deployment when creating an Environment, and change it later in the dashboard under Environment Settings → Deployment. The Environment’s overview shows whether it is currently enabled.
Deploying manually
When Automatic deployment is off (or you just want to force a rollout), trigger a deployment from the CLI:
paas deploy -o acme -p shop -e production -a web
This ships the newest successful build — even in an Environment with Automatic
deployment off — and rolls the app out. Triggering a deployment requires the
deploy permission on the project. In a promote-only Environment, promote
into it instead — paas deploy is refused there.
Build and promote-only Environments
An Environment either builds from source (the default) or is promote-only: it never builds, and its apps run the versions promoted into it from another Environment. Pushes to a promote-only Environment are refused. See Pipelines, Promotion & Rollback.
Tracking what’s deployed
The Deployments tab of an app lists that app’s deployments, newest first — click Load more for older ones. Each entry shows the deployment number, commit message, short SHA, branch, who pushed it, whether it was a build, a promotion or a rollback, and its stage (Received, Queued, Building, Built, Running release command, Releasing, Starting, Live, Failed, Rolled back, Rollback failed, or Replaced), with the newest Live entry marked current. A shared build also names the other apps it deployed. For every app at once, open the Environment’s Deployments tab.
From the CLI:
paas releases -o acme -p shop -e production # recent deployments
paas status -o acme -p shop -e production -a web # current sync/health status
paas deploys list (alias paas releases) shows 25 deployments per page; add
-a web for one app’s deployments, and follow the More: paas deploys list --before <n> hint (or pass --limit, up to 100) for older ones.
Next steps
Secrets & Configuration
Your app’s configuration comes in two forms:
- Environment variables — plain
KEY=VALUEpairs set on an app (a Web Application or Background Worker) or inherited from its Environment or Organization. Use them for non-sensitive settings: feature flags, log levels, public URLs. - Secrets — encrypted values (API keys, passwords, tokens) stored by the platform and injected into apps by reference. Secret values are write-only: once saved, nobody can read them back through the dashboard or the API — you can only replace them.
Both reach your code the same way: as environment variables in the running instance. The difference is how they are stored and who can see them.
Environment variables
Plain configuration inherits from Organization → Environment → app, with the more specific value winning. There is no Project-level plain-variable scope. For example, an app’s LOG_LEVEL=info overrides its Environment’s LOG_LEVEL=debug; removing the app value restores inheritance.
Inherited values never override automatic public URL variables or variables supplied by attachments. An app may explicitly override PAAS_PUBLIC_URL and PAAS_PUBLIC_HOST; avoid reusing attachment variable names for unrelated settings. The platform sets PORT from the app’s port setting.
Use the Environment Variables guide for dashboard and CLI commands, key rules, automatic variables, and permissions. Plain values are visible to people with access to that scope; credentials belong in secrets.
Runtime variables configure the running app. Buildpack settings configure image construction; see runtime selection and static frontend builds. Browser assets expose any values compiled into them; see Designing Apps for d2o.
Secrets
Secrets store encrypted, write-only values. Their names start with an uppercase letter, followed by uppercase letters, digits, or underscores, don’t end with an underscore, and are at most 253 characters. A few endings (_S3, _REDIS_CONN, _MONGODB_PASSWORD, _MARIADB_PASSWORD) are reserved for credentials the platform creates. An app variable key also starts with an uppercase letter, and need not match the secret name.
For example, an app variable PAYMENT_KEY may reference a secret named STRIPE_PAYMENT_KEY. The value reaches the process as PAYMENT_KEY. See Managing Secrets for creation, permissions, rotation, and deletion.
Secret scopes
Secrets live at one of three levels, matching the platform hierarchy:
- Organization — for values shared across the whole organization.
- Project — for values shared by a project’s environments.
- Environment — for values specific to one environment, such as
productioncredentials that staging must never see. This is the most common scope, managed from the environment’s Secrets page.
Choose the narrowest scope that works. A production database password belongs
on the production environment, not on the organization.
How a secret reaches your app
A secret reaches an app only through a variable referencing it. The platform resolves the secret name from the nearest scope: Environment → Project → Organization. Creating a reference fails if no visible secret exists. Your code then reads an ordinary environment variable; it needs no platform SDK.
The binding instructions show both interfaces. Binding or rotating a secret rolls out new instances automatically; deleting and re-creating a referenced secret can require a manual restart. The authoritative timing rules are in When changes reach your app.
Next steps
- Environment Variables — the full guide to per-app configuration
- Managing Secrets — step-by-step secret creation and rotation
- Attaching a Database — connection strings injected for you
- Access Control — who can manage secrets
Access Control
Access on d2o PaaS works in two layers. Your organization role decides what you can do to the organization itself — its members, groups, projects, and settings. Your project capabilities, granted through groups, decide what you can do inside individual projects and their environments.
You manage all of this in the dashboard. The paas CLI can manage organizations, projects, and resources, but membership, groups, and invitations are dashboard features.
Organization roles
Every member of an organization has exactly one role:
| Role | What it grants |
|---|---|
| Owner | Full control of the organization, including deleting it and transferring ownership. Every organization has exactly one Owner. |
| Admin | Manage all members, groups, and projects. |
| Member | No project access on its own — Members reach projects only through the groups they belong to. |
The personal organization created for you when you join the platform is one you own. In a team organization, most people are Members whose actual project access comes from groups.
Project capabilities
Within a project, access is a set of individual capabilities — each one is granted separately, and you can hold any combination of them:
| Capability | What it grants |
|---|---|
| View | Read-only access — resources, log snapshots, and deployment status. |
| Promote & roll back | Promote releases into an Environment and roll apps back to an earlier version, and see build and release logs and follow live logs. |
| Deploy & operate | Push code and trigger deployments, see build and release logs, follow live logs, open a console on running instances, restart, and scale. |
| Manage web apps | Create, update, and delete web app resources. |
| Manage workers | Create, update, and delete worker resources. |
| Manage databases | Create, update, and delete database resources. |
| Manage object storage | Create, update, and delete object storage resources. |
| Manage secrets | Create, update, and delete secrets. |
| Manage environments | Create, update, and delete environments. |
| Manage project | Full control of the project, including its settings and invitations. |
Deployment tokens and GitHub/GitLab repository links follow the same capabilities: an Environment’s need Manage environments, and an app’s own need Manage web apps or Manage workers. Connecting a GitHub or GitLab account to the organization is limited to its Owners and Admins.
The dashboard offers presets — Viewer, Release manager, Developer, Maintainer, Admin — that select common combinations of these capabilities, so you don’t need to pick them one by one. A Release manager (View plus Promote & roll back) can move releases between Environments and undo them, without pushing code or touching running instances.
Owners and Admins automatically hold every project capability in the organization, apart from any capabilities the platform has reserved for its own operators. Members get capabilities only through groups.
Groups
A group is a named set of organization members plus a set of project grants. You create groups in the dashboard under your organization’s settings, add members to them, and then grant each group a set of capabilities on the projects it should reach (or start from a preset).
If you belong to several groups, your effective access to a project is the union of everything those groups grant you.
Per-environment overrides
A group’s grant applies to the whole project by default, but it can be overridden for specific environments. This lets you shape access per environment — for example, treating production differently from the project’s other environments — without splitting the project up.
Project guests
Sometimes you want to give one person access to one project without adding them to your organization at all — a contractor, or a collaborator from another company. That is a project guest.
Guests are added by inviting them directly to a project. The invitation carries the set of capabilities they will hold (organization invitations carry a role instead). A guest:
- has the granted capabilities on that one project, and nothing else in the organization;
- does not appear in the organization’s member list and cannot see organization-level pages — at most the organization’s name;
- is not affected by per-environment overrides — a guest’s grant is project-level only.
You cannot send a project invitation to an email address that already belongs to an organization member; existing members get access through groups instead. Project invitations can be sent by an organization Owner or Admin, or by someone who holds the Manage project capability on that project.
Because the platform is invitation-only, inviting someone who has never used d2o PaaS also creates their account — they receive an email that walks them through setting a password and accepting the invitation.
Next steps
- Groups & Project Permissions — create groups and grant project access step by step
- Inviting People — organization and project invitations in detail
- Managing Your Organization — members, roles, and ownership transfer
- Organizations, Projects & Environments — how the hierarchy fits together
Creating a Web Application
A Web Application is an HTTP service with a web endpoint. It lives inside an Environment, which lives inside a Project, which lives inside an Organization — see Organizations, Projects & Environments if that hierarchy is new to you.
You can create a Web Application in the dashboard or with the paas CLI. Both
paths create the same thing: an app record plus a platform-hosted git
repository. Pushing code to that repository is what triggers a build and
deploy — you don’t upload code at create time.
Before you begin
- You need an existing Environment to create the app in.
- You need permission to manage web applications in the project — without it, the Create button is disabled. See Access Control.
In the dashboard
Open your Organization, then the Project and Environment, and choose Add Resource. Select the Web Application resource type, then fill in the form:
Name — up to 50 lowercase letters, numbers, and hyphens, starting with a
letter and ending with a letter or number (for example web). The name is used in URLs and DNS, so
pick something short and stable.
Framework — the language or stack your code is written in: Ruby on Rails, Node.js, Python, Go, Rust, or Static Files. This selects the buildpack that turns your code into a runnable app, and it is required: an app without a supported framework cannot build, and the platform rejects the create. All six frameworks have hosted builders; see Supported Frameworks for detection files and runtime versions for availability.
Deploy with Git push — the platform hosts the app’s repository for you.
After creating the app you get a remote to push to; pushing to the main
branch triggers a build and deployment. Building from an arbitrary git URL isn’t
supported; to deploy from GitHub or GitLab,
link the repository after creating the app.
See Deploying with Git Push.
Repository — Its own repository (the default) or The environment’s shared repository, with a Directory for where the app’s code lives in it — see One remote for several apps.
Process type — select a process declared by your build, such as web or
worker, or leave blank for the default. This is a process name, not a shell
command. See Procfile.
Port (default 3000) — the port your app listens on for HTTP traffic.
Set this to whatever your server binds to.
Health Check Path (dashboard default /health, or / for Static Files) — a path on your app the platform
requests to confirm an instance is up. Your app should answer it with a
success response. Or set it in d2o-paas.yaml in your repository instead —
see d2o-paas.yaml Reference.
Replicas (default 1) — how many instances of your app run at once. The
form accepts 1 through 10 at creation (scale to 0 later to stop the app);
your plan’s limits also apply (see
Plans, Limits & Usage). You can change this later
or enable autoscaling — see Scaling & Autoscaling.
CPU Limit (default 500m) and Memory Limit (default 512Mi) — the
most CPU and memory each instance may use. CPU is written in millicores
(500m is half a core, 1000m a full core); memory in Mi/Gi (512Mi,
1Gi).
Max egress per replica (Mbps) — an optional per-instance limit on outbound network bandwidth; it counts against your plan’s network capacity. Leave it blank for no limit.
Click Create Resource. The app appears in the environment’s resource list, and stays in a provisioning state until you push code.
With the CLI
paas apps create web \
-o acme -p shop -e production \
--framework nodejs \
--port 3000 --health-check-path /health
Created app web
Only the name is a positional argument; everything else is a flag. Always pass
--framework (rails, nodejs, python, go, rust, or static) —
creates without a supported framework are rejected, and a misspelling such as
--framework node is rejected with a message naming the field and the
accepted values. The other flags mirror the dashboard form:
--port,--health-check-path--replicas(defaults to 1 when omitted),--cpu,--memory--autoscaling-min,--autoscaling-max,--autoscaling-cpu-target— passing any of these creates the app with autoscaling enabled (see Scaling & Autoscaling)--shared-source,--source-dir— build from the Environment’s shared repository (see below)--process(alias--start-command),--release-command,--auto-rollback true|false— see Deployments, Health Checks & Rollback--singleton true— see Singleton apps--worker— creates a Background Worker instead--json— print the raw API response
--port defaults to 3000, the same as the dashboard. The platform passes
the port to your app as the PORT environment variable, so a server that
binds to $PORT needs no flag.
For non-static apps, pass --health-check-path /health explicitly: the CLI
does not choose a path. Static apps default to / at creation. See the
health-check defaults and rollout rules.
--git-repo, --git-branch, and --source-mode also appear in the help, but
building from an arbitrary git URL isn’t supported, and the API rejects
external-source creates. To deploy from GitHub or GitLab,
link the repository after creating the app.
Deploy your code
Once the app exists, point a git remote at its hosted repository and push:
paas git remote -o acme -p shop -e production -a web
git push paas main
paas git remote prints and configures the paas remote for the app. The
push to main triggers the build and deployment. Full walkthrough:
Deploying with Git Push.
Several apps from one repository? Create them with --shared-source and use the
Environment’s remote instead — see
One remote for several apps.
Next steps
Deploying Go
Choose Go (--framework go). Commit a Go modules project with go.mod at
the selected app root, plus go.sum when your dependencies require it.
This is a quick guide for an existing project. For a complete hello → database → worker → storage walkthrough, follow the Go tutorial.
Here is a minimal web server:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil))
}
go mod init example.com/service
go mod tidy
paas apps create service --framework go --health-check-path /health
Commit and push your code. The builder compiles
the main packages and provides a web process for a single-binary app. Add a
Procfile when you need a different command or
multiple process types; compiled binaries are on the launch process’s PATH. For example:
web: service
For several main packages, the buildpack’s // +heroku install comment in
go.mod can select which package paths to compile. Keep the Procfile’s binary
names consistent with those packages.
Declare the compiler requirement in go.mod; see runtime
selection for exact pins, ranges, and the
toolchain distinction. The catalog lists
the deployed builder’s available releases and verified versions.
The platform disables Go’s VCS stamping during builds because the build sandbox
does not expose a normal writable Git checkout. This does not change module
dependency resolution. For build failures, check the requested compiler version,
module files, and package paths. For health failures, bind to all interfaces and
read the injected PORT, then ensure your configured health endpoint succeeds.
Deploying Static Sites
Choose Static Files (--framework static) to serve HTML, CSS, JavaScript,
and other public assets with nginx. This supports prebuilt files and frontend
projects that compile to files. Server-side rendering needs a server framework
such as Node.js instead.
For a first deployment with a downloadable site and a public bucket asset, follow the static tutorial. This guide covers configuring your own frontend.
Prebuilt files
Commit your site under public/, including public/index.html, and create a web
app:
paas apps create site --framework static --health-check-path /
Push it using the app’s Git remote. nginx
listens on the platform’s injected PORT. The home page is the health endpoint;
the API, CLI, and dashboard default new Static Files web apps to / when no
explicit path is provided. Existing app settings are preserved.
Build a frontend project
Define a package script in package.json, for example a Vite project’s build
script. Commit the package manager’s lockfile. The npm and Yarn build workflows
are verified; pnpm is not part of this Static Files build path.
At the app root, add project.toml:
[_]
schema-version = "0.2"
[[io.buildpacks.build.env]]
name = "BP_NODE_RUN_SCRIPTS"
value = "build"
[[io.buildpacks.build.env]]
name = "BP_WEB_SERVER_ROOT"
value = "dist"
The builder installs dependencies and runs the named package script, then serves
dist/ with nginx. BP_NODE_RUN_SCRIPTS takes script names, not shell commands;
put the command in package.json. Change dist to your tool’s output directory
(for example build). These paths are relative to the selected app source
directory. Shared apps with the same framework and source directory share this
build configuration and output.
Use engines.node to select a build-time Node.js version. Paketo’s version
inventory differs from the Node.js application builder; see version
selection and the catalog.
The deployed builder’s Yarn versions and bundled npm version are part of its
frontend toolchain; a package-manager pin outside that inventory can fail a build.
For the verified Yarn Classic workflow, commit yarn.lock and use
engines.yarn in package.json to request a catalog version. npm comes bundled
with the selected Node engine. A successful build is separate from upstream
maintenance; check both columns in the catalog when choosing a toolchain.
Single-page applications
For client-side routes, add:
[[io.buildpacks.build.env]]
name = "BP_WEB_SERVER_ENABLE_PUSH_STATE"
value = "true"
This serves index.html for client-side routes. Leave it unset for a conventional
site where missing pages should return 404. Only publish files intended to be
public; build-time variables compiled into JavaScript are visible to visitors.
Configuration and troubleshooting
project.toml configures the build. d2o-paas.yaml configures the running app,
such as health checks and process selection. Dashboard environment variables
configure the running container; do not rely on them as build-time settings.
The platform supplies BP_WEB_SERVER=nginx; no custom nginx configuration is
needed for these examples.
If a build fails, inspect dependency installation and the named script in the
build log. If deployment health fails, check that the configured output directory
contains index.html and that the health path points to a real file. A /health
path is not automatically created. For a custom nginx configuration, ensure it
listens on the injected port and serves the intended document root.
See Paketo Web Servers for the upstream build settings, and deployments for health checks and rollback.
Pinning nginx
The catalog lists nginx versions from the deployed builder. To select one, set
BP_NGINX_VERSION in project.toml using the same build-env syntax above.
The currently deployed nginx buildpack 1.3.0 also needs a buildpack.yml file
when a version is explicitly selected:
nginx: {}
This works around its missing-file error during detection; the version in
BP_NGINX_VERSION still takes precedence. The default server version needs
neither setting. This workaround is covered by the catalog’s smoke tests.
Creating a Background Worker
A Background Worker is a long-running process without a public web endpoint. It is built and deployed exactly like a Web Application — you push code, the platform builds it with buildpacks and runs it — but it gets no public URL. It can declare an optional health check — see Health checks.
When to use a worker
Choose a worker when your process does its job without serving web requests:
- consuming a queue or processing jobs
- running a bot or a long-lived connection to an external service
- periodic or streaming data processing
For an application HTTP service, create a Web Application. A worker may still listen on a port solely to answer its health check.
Running a bot that must never run twice, such as a chat bot? Make it a singleton.
Before you begin
You need an Environment to create the worker in, and permission to manage workers in that Project (see Access Control). Workers count toward your plan’s limits like any other Resource — see Plans, Limits & Usage.
Create a worker in the dashboard
- In the dashboard, open the Environment (for example
productionin theshopproject) and create a new Resource. - Select the Background Worker resource type.
- Enter a Name — up to 50 lowercase letters, numbers, and hyphens,
starting with a letter (for example
jobs). - Pick the Framework that matches your code — Ruby on Rails, Node.js, Python, Go, Rust, or Static Files (see Supported Frameworks).
- Set Process type to the name your build declares, such as
worker. Leave it blank only when the build’s default process is the worker. This selects a buildpack process, not a shell command; the same field is editable later in Settings. - Optionally adjust Replicas (how many instances run; default 1), CPU Limit, Memory Limit, and Max egress per replica (Mbps) (outbound bandwidth per instance; unlimited when left blank).
- Click Create Resource.
There is no Networking section for a worker — no port, since it takes no
public traffic. A worker’s health check, if you set one, lives under its own
settings instead — see Health checks. Or set
it, along with the worker’s process, in d2o-paas.yaml in your repository —
see d2o-paas.yaml Reference.
The platform hosts a git repository for your worker’s source. After creating
it, you get a remote to push to; pushing to the main branch triggers a build
and deploy.
Create a worker with the CLI
paas apps create creates a Web Application by default; add --worker to
create a Background Worker:
paas apps create jobs --worker \
-o acme -p shop -e production \
--framework nodejs
Useful options for workers:
--framework <FRAMEWORK>— the buildpack framework for your code--process <PROCESS>(alias--start-command) — select a named process such asworker--replicas <REPLICAS>— how many instances to run--cpu <CPU>/--memory <MEMORY>— per-instance resource limits--health-check-path <PATH>or--health-check-command <COMMAND>— optional; without either, a new instance just has to run for 30 seconds without restarting to count as healthy (see Health checks)--git-repo <GIT_REPO>/--git-branch <GIT_BRANCH>— reserved for building from an arbitrary git URL, which isn’t supported; push to the platform-hosted repository (see Deploying with Git Push), or deploy from GitHub or GitLab--autoscaling-min,--autoscaling-max,--autoscaling-cpu-target— enable CPU-based autoscaling (see Scaling & Autoscaling)
A worker never gets a public URL. If you give it a health check path, it
must answer on the port in $PORT — 8080 unless you set --port.
Deploy your code
From your worker’s source directory, configure the paas git remote and push:
cd my-worker
paas git remote -o acme -p shop -e production -a jobs
git push paas main
The push triggers a build and deployment. Follow along with:
paas status -o acme -p shop -e production -a jobs
paas logs -a jobs --tail
See How Builds & Deployments Work for what happens between the push and your worker running.
Several apps from one repository? Create them with --shared-source and use the
Environment’s remote instead — see
One remote for several apps.
What a worker doesn’t have
Because a worker serves no HTTP traffic, there is no public URL and no custom domain. A health check is optional — see Health checks — and without one the platform just waits for a new instance to stay up for 30 seconds. Everything else works the same as for a Web Application: environment variables, secrets, database attachments, scaling, restarts, logs, metrics, and the console.
Next steps
-
Designing Apps for d2o — shutdown and retry-safe jobs
-
Environment Variables — configure your worker
-
Attaching a Database — give it a database connection
-
Logs — watch what it’s doing
-
Scaling & Autoscaling — run more instances
For complete web and worker examples sharing one build, follow the language tutorials.
Environment Variables
Environment variables configure a single Resource — one app in one Environment. The same app in production and staging keeps separate sets, so you can point each at different services without touching code.
Settings that many apps share don’t have to be repeated on each one: set them once on the Organization or the Environment and every app there inherits them — see Organization and environment variables.
Keys must start with an uppercase letter and contain only uppercase letters, digits, and underscores (DATABASE_URL, NODE_ENV). The dashboard normalizes what you type automatically; the CLI and API reject invalid keys.
Setting, changing, or deleting a variable requires the manage capability on the project (see Access Control) and triggers a new deployment of the app so running instances pick up the change.
In the dashboard
Open your app’s Config tab and find the Environment Variables section, then click Add Variable. Each variable takes one of three value sources:
- Plain value — stored and displayed as-is. Good for non-sensitive settings like
NODE_ENV. - Secret value (hidden) — the value is stored as an encrypted secret in this Environment, named after the variable’s key, and linked to it. Your app receives it like any other variable; nobody can read it back afterwards. Because it creates or replaces a secret, it needs the Manage secrets capability on the Environment, not just permission to manage the app. A name that’s already a secret here is refused: another app’s linked secret, one made on the Secrets page, or a Project secret (and, for organization owners and admins, an Organization secret). Writing it would change or override that secret for every app here, so link the variable to it instead, or pick another name.
- Link to secret — the variable takes its value from a platform-managed secret you pick from a list. If no secret is available to link, the form links you to create one.
Existing variables can be edited or deleted inline. Deleting asks for confirmation, since it also redeploys the app. A hidden variable stays hidden: saving a plain value over it is refused, so delete it first if you want to store a plain one.
The section also lists the variables the app inherits from its Organization and Environment, showing where each one comes from and whether the app overrides it.
With the CLI
paas config manages variables for the app selected with -a, scoped by -o/--org, -p/--project, and -e/--env.
List the current variables:
paas config list -o acme -p shop -e production -a web
Set one or more variables. Pairs are applied in order, and the command stops at the first failure:
paas config set NODE_ENV=production LOG_LEVEL=info -o acme -p shop -e production -a web
Store a hidden value with --hidden: the CLI’s version of Secret value (hidden), with the same rules and capability. KEY=- reads the value from stdin, so it stays out of your shell history:
printf %s "$API_TOKEN" | paas config set --hidden API_TOKEN=- -o acme -p shop -e production -a web
Remove a variable (one key per command):
paas config unset LOG_LEVEL -o acme -p shop -e production -a web
paas config list also shows the variables the app inherits; --json prints the raw API response for the app’s own variables only.
Linking a variable to a secret
Select Link to secret for a sensitive value. A variable key can differ from the secret name; the referenced secret must already exist in a visible scope. Follow Binding a secret to an app for the dashboard and CLI commands.
Organization and environment variables
Besides its own variables, every Web Application and Background Worker inherits plain variables set on its Organization and on its Environment. Use them for settings that many apps share — a log level for the whole staging Environment, a region or company-wide feature flag for every app in the Organization — instead of copying the same value onto each app.
The app overrides its Environment, which overrides its Organization. There is no Project-level plain-variable scope. See configuration precedence for the complete rules and an example.
A few things to know:
- Plain values only. Organization and Environment variables are stored and shown as-is, and can’t link to a secret. Anyone who can view the Organization or Environment can read them, so keep passwords and API keys in secrets and link them from the app.
- Automatic and attachment variables win. Variables the platform sets for an app —
PAAS_PUBLIC_URL,PAAS_PUBLIC_HOST, and the variables an attached database or bucket injects — are never replaced by an inherited variable. An inherited variable with one of those keys is skipped for that app and shown as overridden. - Referencing inherited values. An app’s own variables can use an inherited one, just as they can use the automatic variables — for example
API_URLset to$(API_BASE)/v2. - Changes roll out on their own. Setting or removing an Organization or Environment variable redeploys every app it applies to, so their instances pick up the new value. There is nothing to restart by hand.
Keys follow the same rules as app variables, and a value can be up to 10,000 characters.
Who can change them
| Level | Who can read | Who can set or remove |
|---|---|---|
| Organization | Any Organization member (project guests can’t) | Organization Owners and Admins |
| Environment | Anyone with View on the Environment | Anyone with Manage environments on the Environment |
An Environment’s capabilities come from the Project unless a per-environment override changes them. See Access Control.
In the dashboard
- Organization variables — open the Organization, go to Settings, then Variables.
- Environment variables — open the Environment, go to Settings, then Variables.
Add, edit, or remove variables there. The app’s own Environment Variables section shows what it inherits from both levels.
With the CLI
paas config takes --scope org|env|app (default app). With --scope org it needs only the Organization (-o); with --scope env it needs the Organization, Project, and Environment (-o, -p, -e); the default app scope also needs -a.
# Every app in the Organization
paas config set --scope org LOG_LEVEL=warn -o acme
# Every app in the staging Environment
paas config set --scope env LOG_LEVEL=debug -o acme -p shop -e staging
paas config list --scope env -o acme -p shop -e staging
# Remove an inherited variable
paas config unset --scope org LOG_LEVEL -o acme
--secret-ref works with the app scope only, since Organization and Environment variables are plain values.
Automatic variables
Apps with a public hostname (Web Applications) get two variables injected automatically:
PAAS_PUBLIC_URL— the app’s full public URL, e.g.https://dzijyc9c.cx.<your-platform-domain>— an eight-character random label under the app domain your platform’s operators configuredPAAS_PUBLIC_HOST— the hostname alone
Because public hostnames are randomly generated, your app cannot guess its own address at build time; use these wherever the app needs to name itself — an OAuth redirect URL, absolute links in email, a webhook it registers. Other variables you define can reference them, for example CALLBACK_URL set to $(PAAS_PUBLIC_URL)/oauth/callback.
If you define your own PAAS_PUBLIC_URL or PAAS_PUBLIC_HOST on the app — say, behind a proxy or on a custom domain — your value wins and the platform’s is not injected. (An Organization or Environment variable with one of these keys does not; see above.) Background Workers and Databases have no public hostname, and Object Storage buckets don’t run your code, so none of them receive these variables.
Every Web Application and Background Worker also gets PORT, set to the app’s port: 3000 unless you chose another, and 8080 for a worker without one. Bind your server to it.
When a restart is needed
Variable changes and secret rotation roll out automatically. The delete-and-recreate exception and its restart command are documented in When changes reach your app. Configuration rollouts are not numbered code deploys and do not provide automatic rollback; try changes in staging first.
Next steps
- Managing Secrets — create and update the secrets your variables link to
- Attaching a Database — connection details injected as an environment variable
- Secrets & Configuration — how configuration flows through the platform
- Deployments, Health Checks & Rollback — the deployments your changes trigger
Scaling & Autoscaling
Web Applications and Background Workers run as one or more identical instances. You can set the instance count yourself, or turn on CPU autoscaling and let the platform adjust it within a range you choose. This page covers both, plus the CPU and memory settings that apply to each instance.
Databases and Object Storage buckets are not scaled this way: a Database’s instances are managed by the platform, and a bucket has no instances at all.
Scale manually
Scaling needs the deploy permission on the project (see Access Control).
In the dashboard, open the app’s Overview page. The Replicas row shows the current count with + and − buttons next to it. Scaling down to zero stops the app entirely, so the dashboard asks you to confirm first. You can also set the replica count on the app’s Settings tab — that save goes through the same form as CPU and memory, so it needs the manage permission rather than deploy.
With the CLI, use paas scale:
paas scale 3 -o acme -p shop -e production -a web
Scaling to 0 stops the app; scale back up to start it again. Check the running instances with:
paas ps -o acme -p shop -e production -a web
The change rolls out automatically — you do not need to trigger a deployment.
Scaling is checked against your Organization’s plan limits, since each added instance consumes capacity. If a scale request would exceed them, it is rejected with a limit-exceeded error; see Plans, Limits & Usage. An app stopped by the monthly transfer cap can’t be scaled until the allocation is raised or the month rolls over — see Monthly transfer allocation.
Running on more than one server
Run two or more instances (or autoscale) and the platform spreads them evenly across its servers, so a server failure leaves the others serving; maintenance moves one instance at a time. A single-instance app is unavailable while its one instance is moved — run at least two if that matters.
CPU and memory per instance
Every instance of an app runs with the same CPU and memory limits. Set them on the app’s Settings tab in the dashboard (this needs the manage permission):
- CPU Limit — for example
500m(half a CPU core) or1(one full core). - Memory Limit — for example
512Mior1Gi.
Saving rolls the change out to all instances automatically. Like replicas, these sizes count against your plan’s capacity, so raising them for a heavily scaled app can hit a limit.
Autoscaling
Instead of picking a fixed count, you can have the platform scale the app on CPU usage. Autoscaling is opt-in and per app, and is available for Web Applications and Background Workers.
On the app’s Overview page, the Autoscaling row shows the current state. Click Enable… and set:
- Min replicas — the count the app never drops below (at least 1).
- Max replicas — the ceiling; must be greater than or equal to the minimum.
- Target CPU % — the average CPU use (1–100) the autoscaler aims for, as a percentage of each instance’s baseline of 0.1 core (
100m, or the CPU Limit if that is lower), not of its CPU Limit. For example, 70 adds instances once they average70m. When instances run hotter than the target, more are added, up to the max; when they run cooler, instances are removed, down to the min.
While autoscaling is on, the Replicas row is marked (managed by autoscaler) and the manual +/− buttons disappear. To see how many instances are actually running, check the running instances (for example with paas ps). Plan limits are enforced against the configured max, since that is the real peak the app can reach.
To go back to a fixed count, click Disable on the Autoscaling row. Your previous min/max/target values are remembered, so re-enabling later does not reset them.
With the CLI, passing any of --autoscaling-min, --autoscaling-max or --autoscaling-cpu-target to paas apps update (or paas apps create) turns autoscaling on; an unset target means 70:
paas apps update web --autoscaling-min 2 --autoscaling-max 6 --autoscaling-cpu-target 70 -o acme -p shop -e production
To turn it off, pass --autoscaling false; the app goes back to its fixed instance count:
paas apps update web --autoscaling false -o acme -p shop -e production
Manual scaling conflicts with autoscaling
While autoscaling is on, the autoscaler owns the replica count — a manual value would just be overwritten. A paas scale (or a scale request from any other client) is therefore rejected with a conflict:
$ paas scale 5 -o acme -p shop -e production -a web
Error: CONFLICT: autoscaling is enabled; disable it to set a fixed replica count
Disable autoscaling first, then scale manually.
Singleton apps
A singleton app runs at most one instance. Deployments and restarts stop the old instance before starting the new one, so expect a few seconds of downtime. Use it for bots and other apps that must never run twice.
Normally a deployment starts the new instance before stopping the old one, so the app stays up throughout — but for a moment two copies run side by side. For most apps that is harmless. For a bot that keeps one live connection per token to a chat service, two copies mean two sessions on the same token: duplicate replies, or the service disconnecting both.
A singleton never overlaps. Every rollout — a deployment, a restart, or a configuration change — stops the old instance first and only then starts the new one. The cost is a gap while the old instance stops and the new one starts, so leave it off for Web Applications that need to stay up through deployments. How long the gap lasts depends on how quickly your app shuts down: the platform sends it SIGTERM and waits for it to exit. An app that exits promptly on SIGTERM is down for a few seconds; one that ignores it is force-stopped after 30 seconds, so its gap is about 30 seconds longer.
Singleton is off by default and available for Web Applications and Background Workers. A singleton app:
- can’t autoscale — turn off autoscaling first (click Disable in the app’s Autoscaling row, or
paas apps update <app> --autoscaling false); - runs at most one instance — the replica count can be
1, or0to stop the app. Scaling a singleton above 1 is rejected with a conflict:
$ paas scale 2 -o acme -p shop -e production -a bot
Error: CONFLICT: singleton apps run at most one instance; turn off singleton to scale out
In the dashboard, open the app’s Overview page and tick Run at most one instance in the Singleton row. It applies right away (this needs the manage permission). If the app is autoscaling or running more than one instance, the box stays disabled until you turn off autoscaling or scale down to one.
With the CLI, pass --singleton true to paas apps update (or to paas apps create):
paas apps update bot --singleton true -o acme -p shop -e production
Add --replicas 1 if the app runs more than one instance today. Pass --singleton false to turn it off again; paas apps info shows whether an app is a singleton.
Or set singleton: true in d2o-paas.yaml; the checkbox then shows as set by your code.
Next steps
- Metrics — watch CPU and memory usage to pick sensible limits and targets
- Restarting an App — roll all instances without changing the count
- Plans, Limits & Usage — how scaling counts against your Organization’s capacity
Deployments, Health Checks & Rollback
Every push, promotion, and rollback is a deployment — a numbered record you can follow from the moment it starts to the moment it’s serving traffic, or fails with a reason you can act on. This page covers the stages a deployment goes through, how to follow one live, health checks, the release command, and rolling back.
What happens after git push
| Stage | What’s happening |
|---|---|
| Received | Your push — or a promote or rollback you triggered — was accepted and given a deployment number. |
| Queued | Waiting for a build slot. |
| Building | preparing → detecting your app → restoring cache → building → exporting. The build log streams live. |
| Built | The new version is ready. With Automatic deployment on, it moves straight into release; with Automatic deployment off it waits here as waiting to be deployed until you deploy it. |
| Running release command | If the app has a release command, it runs once against the new version before anything else starts. |
| Releasing | The new version is being rolled out. |
| Starting | New instances are starting and being health-checked. Old instances keep serving traffic until the new ones pass. |
| Live | The new version is serving traffic. |
A deployment that doesn’t reach Live ends in one of these instead:
| Outcome | Meaning |
|---|---|
| Failed | The build or release command failed (the previous version keeps running), or there was no earlier version to go back to. |
| Rolled back | The new version wasn’t healthy, and the previous version was restored. |
| Rollback failed | The new version wasn’t healthy, and the previous version wasn’t healthy either — or automatic rollback is off for this app. The platform takes no further automatic action — see When a new version isn’t healthy. |
| Replaced | A newer push or deployment superseded this one before it finished. |
A build that doesn’t start within 30 minutes of the push, stops reporting for 5 minutes, or runs longer than 3 hours is marked Failed with the reason; push again. (A 3-hour build that does eventually finish is still deployed.) A promotion or rollback that isn’t applied within 10 minutes is marked Failed too.
See Health checks and Release command for what can make a deployment fail.
Following a deployment
A push prints its deployment number, a link to watch it live, and the paas watch command to run:
remote: Deploy #42 started for abc1234 → web, worker
remote: Follow live: https://d2o.io/orgs/acme/projects/shop/envs/production/deploys/42
remote: Or run: paas watch --deploy 42
The push returns as soon as the deployment is created — it doesn’t wait for the build.
In the dashboard, the printed link opens the deployment page: a stepper for the current stage, one row per app with its health-check progress (or the reason it’s waiting), live build and release logs, and — if it fails — the failure reason and a Roll back button. While a deployment is in flight, the app and environment pages, and the project’s pipeline board, show a live “Deployment #42 · Building” chip that links to the same page. The app’s Deployments tab lists that app’s deployments (a shared build also names its other apps); the Environment’s Deployments tab lists every app’s.
From the CLI, paas watch follows a deployment stage by stage and streams its build and release logs until it finishes:
paas watch --deploy 42
Without --deploy, it follows the newest deployment for the app or Environment inferred from your paas git remote. Useful flags:
--quiet— print stage changes only, without the log output.--timeout <SECONDS>— how long to wait before giving up (default1800).
paas watch only exits once the deployment is final — including after an automatic rollback finishes — and retries on a network blip rather than giving up. It exits 0 when the deployment reaches Live, 1 when it ends Failed, Rolled back, Rollback failed, or Replaced (a newer deployment took over), and 2 on a timeout or a usage/connection problem — so it’s a reasonable gate in a CI pipeline:
git push paas main && paas watch
Like paas logs --tail, it streams, so it rejects --json. Watching requires an account API token; a Git deployment token cannot authenticate it. See CI deployment verification.
Health checks
With a health check configured, a new version must pass it before taking traffic.
| App at creation | Dashboard default | CLI/API default when omitted |
|---|---|---|
| Dynamic web app | /health | No HTTP check |
| Static web app | / | / |
| Worker | No check | No check |
Choose a path your app actually serves; framework conventions such as Rails’ /up are not selected automatically. An explicit path or repo-declared setting overrides the creation default. Probed instances must remain Ready for 10 seconds before the rollout treats them as available.
- Web Applications use the health check path you configure (
--health-check-path, or the equivalent dashboard field). The platform requests it from each new instance and waits for a successful response before sending it traffic. A Web Application with no health check path set gets no HTTP check: a new instance counts as healthy once it has run for 10 seconds. - Background Workers have no public URL, so a health check is optional. Set either a path (checked over HTTP on the port in
$PORT— 8080 unless you set one) or a command (--health-check-command) that exits0when the worker is healthy. Without either, a new instance just has to run for 30 seconds without restarting to count as healthy.
A health check can also live in your repository’s d2o-paas.yaml instead of the dashboard or CLI. A version that sets it that way locks the field — it shows as read-only until a later version stops setting it. See Move Settings Into Your Repo.
The check keeps running after the deployment, too: an instance that fails it for about 15 seconds stops receiving traffic, and one that keeps failing for about 30 seconds is restarted.
What makes a good check: answer within a second, don’t put it behind login or any authentication your health checker won’t have, and report whether your app has started and can serve. Don’t make it fail just because a dependency is briefly unreachable — if the check queries your database and the database is down for half a minute, every instance fails at once and all of them are restarted together. See Designing Apps for d2o.
When a new version isn’t healthy
A rollout fails after 5 minutes without progress toward healthy new instances. The platform fails it sooner, without waiting out the full 5 minutes, when:
- the container cannot start because its required configuration is unavailable — failed right away. App secret references are optional at container startup: a deleted secret can leave a variable absent instead. Validate required settings in your app;
- it keeps exiting, after 3 restarts — reported as “Your app keeps exiting (exit code N)”, along with its recent log output — or, when the health check is what keeps failing, the check’s own result, e.g. “Health check /health returned 503”;
- it can’t be loaded, or there isn’t enough free capacity on our servers to start it — after 2 minutes of that. A capacity failure is a problem on our side, not in your app.
Automatic rollback is an app setting — the Automatic rollback checkbox under the app’s Settings → Deployments, or --auto-rollback — on by default. When a new version fails, the platform deploys the last version that reached Live instead, and health-gates that rollback too — it has to become healthy in turn:
- If it does, the deployment is marked Rolled back.
- If even the previous version doesn’t come up healthy, the deployment is marked Rollback failed and the platform stops — it never flip-flops between two broken versions — and emails the organization’s owner.
Turn automatic rollback off for an app that should never silently revert on its own; an unhealthy new version then stays Rollback failed, with the previous version still running. Either way, the deployment page and paas deploys info show what happened and why.
Apps set to run at most one instance (singletons) are briefly down during a rollback: the broken instance has to stop before the previous version can start in its place.
Release command
A release command runs once against the new version, before any new instance starts — the place for a versioned migration before the app starts. Make it safe to retry; see the execution rules below.
Set it as release.command in d2o-paas.yaml, as a release: line in your app’s Procfile, or as the Release command app setting (--release-command). When more than one is present, the file wins over the Procfile, and the Procfile wins over the app setting — whichever is set furthest up that list is the one that runs, and the deployment timeline says so when the Procfile overrides the app setting:
Your app's release command setting is ignored because the Procfile defines `release:`
It runs under bash, with the same environment variables and secrets as the app itself.
- If it exits non-zero or runs longer than 15 minutes, the deployment fails at Running release command, and the previous version keeps running, untouched.
- It runs once per new version — a deployment, promotion, or manual rollback that changes the running version. An automatic rollback doesn’t run it again: the previous version already ran it when it went live. A deployment triggered only by a setting or environment-variable change doesn’t run it either, since the version hasn’t changed. Still, write it to be safe to run more than once: a migration tool that skips migrations it already applied is safe this way; a script that unconditionally inserts a row is not.
- For a shared build (several apps built from one repo), it runs once — for one app, before the others start — not once per app.
- A promotion runs the release command in the target Environment, against the target’s database.
Database migrations and rollbacks
Rollbacks restore code, not data. If a deployment’s release command already ran a migration before the deployment was rolled back, that migration is not undone — the previous code keeps running against the new schema. Write migrations so that’s safe:
- Add first, remove later. Add a new column or table in one deployment and start writing to it; only drop the old one in a later deployment, once nothing reads it anymore.
- Widen before you narrow. Add a new enum value (or loosen a constraint) in a deployment before any code writes it, so a rollback to the previous code isn’t left looking at a value it doesn’t understand.
- Never edit a migration that already ran. Write a new one instead — an edited migration can behave differently on an Environment that already applied the old version of it.
For apps with a PostgreSQL database attached, every deployment whose release command runs records a data restore point — the moment right before the command started — shown on the deployment page. It marks a point you could restore the database to; restoring is a separate, deliberate action, not something a rollback does for you.
Rolling back by hand
A deployment that failed can be rolled back manually too — useful when automatic rollback is off. You need the Promote & roll back capability on the Environment.
In the dashboard, open the failed deployment’s page and click Roll back.
From the CLI:
paas deploys rollback 42
Without a number, it rolls back the Environment’s most recent deployment, if that one ended Failed or Rollback failed; when a newer deployment is live, it stops and asks for a number instead. A manual rollback of deployment N goes back to the version that was live before N. It asks for confirmation; skip that with --yes. Only the most recent deployment can be rolled back this way — for an earlier one, push a fix (or revert the commit and push that) in a build Environment, or promote the version you want in a promote-only one.
This is different from
paas rollback --to <id>, which re-pins a promote-only Environment to any earlier version it has run, healthy or not — see Pipelines, Promotion & Rollback.
Build and release logs
Build and release logs stream live on the deployment page (and through paas watch, unless you pass --quiet) while they’re running; seeing them needs Deploy & operate or Promote & roll back. Afterwards, each deployment keeps its build log (the last 1 MB) for the Environment’s 1,000 most recent deployments, and its release log alongside it:
paas logs --build 42
paas logs --build 42 --release
When the deployment covers more than one app (a shared build or a push to an Environment remote), pass -a <APP> to pick whose build or release log to print — it’s required. If a log wasn’t saved and is older than 30 days, or the underlying log store is unreachable, the platform says so rather than showing nothing: “This log is no longer available.”
These are separate from your running app’s own logs — see Logs.
Next steps
- How Builds & Deployments Work
- Pipelines, Promotion & Rollback
- Creating a Background Worker — worker health checks
- Logs
- Restarting an App
- d2o-paas.yaml Reference and Procfile — move these settings into your repository
Deploying from CI
A deployment token lets a CI pipeline (GitHub Actions, GitLab CI, or any other) push to d2o without anyone’s personal credentials. Each token can push to exactly one remote: an environment’s remote, or one app’s own remote. It keeps working when the person who created it leaves your organization.
Create a token
In the dashboard, open the environment’s Settings → Deployment tokens (or an app’s Settings for the app’s own remote) and choose Create deployment token. Copy the token straight away: it is shown once. With the CLI:
paas deploy-tokens create ci-main -e production
paas deploy-tokens create ci-admin -e production -a admin --expires 90
A token never expires unless you choose 30, 90 or 365 days when creating it.
Creating or revoking an Environment’s tokens requires manage access to the Environment; an app’s tokens
require manage access to the app. App tokens are listed and revoked with -a <app> too. An app that
builds from the Environment’s shared remote has no token of its own: create the token on the
Environment instead.
Save the token in your CI as a secret named D2O_DEPLOY_TOKEN.
GitHub Actions
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git push "https://x-access-token:${{ secrets.D2O_DEPLOY_TOKEN }}@push.d2o.io/sources/acme/shop/production.git" HEAD:main
GitLab CI
deploy:
stage: deploy
variables:
GIT_DEPTH: 0
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- git push "https://x-access-token:${D2O_DEPLOY_TOKEN}@push.d2o.io/sources/acme/shop/production.git" HEAD:main
Use the remote shown when you create the token. GIT_DEPTH: 0 and fetch-depth: 0 give the push the
full history it needs.
Verify the deployment
Run your application’s tests before the push step and limit production credentials to the intended branch and job. A successful git push means d2o accepted the source; the build or rollout can still fail afterward. Inspect the Deployment until it reaches Live.
For a scripted gate, supply a separate account API token through your CI secret store (PAAS_API_URL and PAAS_API_TOKEN), then watch the deployment:
paas watch -o acme -p shop -e production -a web
A deployment token authenticates Git only and cannot run paas watch or paas manifest check. Account API tokens act with their owner’s permissions; see API Access & Automation. If several jobs can push concurrently, use paas watch --deploy DEPLOY_NUMBER for the specific push instead of following whichever deployment is newest. See deployment tracking.
When a repository is linked directly to GitHub or GitLab, d2o reports deployment status on the commit and can wait for the commit’s CI results. That is another option when you do not want a CI job to hold an account API token.
Rotate or revoke
To rotate, create a new token, update the CI secret, then revoke the old one. Revoking stops the token immediately:
paas deploy-tokens list -e production
paas deploy-tokens revoke ci-main -e production
Deployments made with a token show it as the pusher, for example “deployment token ci-main”. Promote-only environments don’t accept pushes, so they don’t offer deployment tokens.
Deploy from GitHub or GitLab
Link an Environment or an app to a repo and branch on GitHub or GitLab, and every push deploys it automatically — no CI pipeline or deployment token required.
Connect a provider
Connections are made per Organization, in Organization Settings → Integrations. Connecting
requires being an owner or admin of the Organization. From the CLI, paas connections connect -o acme
prints the Integrations page link; connecting itself happens in the browser.
GitHub
- Click Connect GitHub. GitHub asks you to install the d2o PaaS app on an account or organization and choose which repositories it can see.
- Only an owner of that GitHub account or organization can connect it — the app requests read access to organization members to confirm this. If someone who isn’t an owner installs it, GitHub holds the installation for an owner to approve; connect again once it’s approved.
- You’re returned to the Integrations page with a “GitHub connected” message, and the connection shows as Active.
A broken connection shows Reconnect; every connection also has a Disconnect button. Disconnecting in d2o removes the connection and its repo links, but does not uninstall the app from GitHub. To remove its access there too, uninstall it on GitHub: for a personal account, Settings → Applications; for an organization, the organization’s Settings → GitHub Apps.
GitLab.com
Click Connect GitLab and authorize d2o on GitLab’s consent screen. You’re returned to the Integrations page with a “GitLab connected” message, and the connection shows as Active.
Self-managed GitLab
Self-managed instances use an OAuth application you register yourself, in this order — GitLab only issues the Application ID and Secret once the application exists, so registration comes first:
- Click Connect self-managed GitLab. The form shows the Redirect URI to register with a Copy button.
- On your GitLab instance, go to User Settings → Applications (or Admin → Applications for
an instance-wide application) and create a new application with that Redirect URI,
Confidential checked, and the
apiandread_apiscopes. - Fill in Host, Client ID (GitLab’s Application ID) and Client secret (GitLab’s Secret), then click Continue.
- Authorize on GitLab.
Your GitLab instance must be reachable over HTTPS from the internet for d2o to exchange tokens and deliver webhooks.
An application registered with only the api scope still deploys, but nobody can choose a
repository through it: add the read_api scope to the application on your GitLab instance (no
need to reconnect).
Link a repo to an Environment or app
Linking makes a repo and branch the source of truth for a deployment target. Whether you link the Environment or an app depends on how the app builds:
- An app that builds from its own remote — link the app, in the app’s Settings.
- An app that builds from the Environment’s shared remote — link the Environment instead, in the Environment’s Settings → Deploy from GitHub/GitLab tab.
Linking the Environment needs at least one app that builds from its shared remote (created with
paas apps create NAME --shared-source); until one exists, a repo linked there would never deploy,
so linking it is refused with “No app in this environment builds from its shared source”. Link the
app instead, or create the shared-source app first. Don’t link the same repo to both the
Environment and an app that builds from its own remote — only the app’s link can deploy it.
The same repo and branch can feed several Environments or apps at once — for example a staging and
a production Environment both deploying from main.
In the dashboard:
- Open the Environment’s Settings → Deploy from GitHub/GitLab tab (or the app’s Settings for an app’s own remote — it’s the same editor).
- Pick a Connection, then search for the repo in Repository and pick a Branch.
- Review Wait for CI (enabled by default) and Allow direct pushes (disabled by default), then click Save. The summary shows Provider, Repository, Branch, Wait for CI and Direct pushes, one per line.
Linking a GitLab repository uses your own GitLab account. The picker shows only projects where you are a Maintainer, and linking a project needs Maintainer access to it. The first time, click Choose with your GitLab account (for a target that is already linked, click Edit settings first); GitLab asks you to authorize d2o to read your projects, and the verification lasts about two hours. Deploys still run through the organization’s GitLab connection, so its account needs access to the project too. Changing only the branch or options of an existing link needs no verification.
To change the connection, repository, branch or either option, choose Edit settings. Save changes applies the form together; Cancel discards it. Saving settings does not start a deployment by itself. Disabling Wait for CI can release requests already waiting for checks when you save.
If settings save but automatic updates cannot be connected, the page says so and offers Retry. Your saved settings remain visible.
With the CLI:
paas connections list
paas links set --connection <id> --repo acme/shop --branch production -e production
paas links set --connection <id> --repo acme/shop --branch production -e production -a web
paas links get -e production
--connection <id> is the ID from paas connections list. --repo takes owner/name or a numeric
repo id. paas links get prints the same “Deploys from …” line, plus whether direct pushes are
allowed, whether it waits for CI, and a “Last push” line for the most recent push’s state.
What happens on a push
Pushing a commit to the linked branch deploys it — the same deployment that a direct push to d2o’s own remote would trigger. There’s nothing else to configure.
- Branch deletions and tags are ignored.
- A force-push deploys the branch’s new head, even if it rewrites history.
- If two pushes land close together, only the later commit deploys — the one it replaces is simply superseded, not deployed.
Wait for CI
Wait for CI is enabled by default, including on existing links. A push waits for the commit’s checks before deploying:
- On GitHub: the commit’s check runs and commit statuses. Skipped or neutral checks count as passed; cancelled, timed-out or action-required ones count as failed.
- On GitLab: the commit’s job statuses on the linked branch, plus any statuses other tools post,
using each job’s latest run. Jobs allowed to fail, and skipped jobs, never block. A manual job
that isn’t allowed to fail blocks the deployment until someone runs it; after 2 hours the push is
refused. A commit pushed with
[skip ci]has no checks, so it deploys after 10 minutes. - d2o’s own
d2o: …checks never count.
If the commit has no checks at all, d2o waits 10 minutes and then deploys it anyway, with a “No checks found; deployed without waiting” note. If checks are still running after 2 hours, the push is refused instead of deploying.
If a newer push arrives on the same branch while an earlier one is still waiting on checks, the earlier push is dropped and never deployments, even if its checks later succeed: its check on the commit shows “Replaced by a newer push”, while inside d2o the push itself shows “A newer push replaced this one.”
To change it in the dashboard, choose Edit settings, change Wait for CI, then Save changes. You can also set it explicitly with the CLI:
paas links set --connection <id> --repo acme/shop --branch production -e production --wait-for-ci true
The current CLI replaces the whole link and sends an explicit value for this option. Include --wait-for-ci true when saving through the CLI to keep it enabled.
set replaces the whole link, so --wait-for-ci must be repeated on every set to keep it on (the
same as --allow-direct-push).
Status on your commits
Every push to a linked branch gets its own check on the commit, named d2o: production for an
Environment link or d2o: production/web for an app link.
- On GitHub, it shows up among the commit’s checks, and on any pull request whose head is that commit.
- On GitLab, it’s a commit status, and it also shows in the pipeline view.
The check moves through the same stages as the deployment — queued, then in progress, then a final state (success, or failure/cancelled on GitHub and failed/canceled on GitLab) — with a summary such as “Waiting for checks”, “Building”, “Rolling out”, “Live”, a deployment failure reason, a checks-failed or timed-out detail, or “Replaced by a newer push”. Clicking it opens the deployment on d2o, or — before a deployment exists yet — the Environment’s Deployments page.
On GitHub, the check’s heading names the outcome and the deployment — for example “Live in production — deployment #12” — and its details say what happened (where each app is live, or which app failed at which step, with a link to its logs), followed by a table of the deployment, project / environment, commit, who pushed it and, once live, how long it took from push to live. GitLab shows only the short summary.
A push that ends without deploying for a reason other than a failure — replaced by a newer push or deployment, built but waiting for you to deploy it (“Built — deploy it from d2o to go live”, when the Environment’s Automatic deployment is off), or unlinked (“Unlinked from d2o”) — ends as neutral on GitHub.
GitLab counts d2o’s status as part of your pipeline, so it differs in three ways:
- No status is added while d2o waits for your checks, so it never holds your pipeline open. It appears once the build starts. A push replaced, unlinked or timed out before that gets no status at all — its entry in d2o’s Deployments page still says why.
- A build waiting for you to deploy it shows as success: the build itself succeeded.
- A deployment replaced by a newer one, or unlinked partway, ends as canceled, which also marks that pipeline canceled.
Direct pushes
Once a target is linked, direct pushes to its d2o remote are blocked by default, so what’s running always matches the linked repo. A blocked push is refused with:
This environment deploys from GitHub acme/shop@production. Push there instead, or allow direct pushes in the environment's settings.
(An app-linked target says “This app deploys from … in the app’s settings.”)
Cloning and fetching from the d2o remote are never blocked — only pushes.
To allow direct pushes alongside the linked repo, choose Edit settings, turn on Allow direct pushes, and Save changes on the same
settings tab, or re-run paas links set with --allow-direct-push (it replaces the whole link, so
the flag must be repeated):
paas links set --connection <id> --repo acme/shop --branch production -e production --allow-direct-push
Deploy latest
Deploy the branch’s current head on demand, without waiting for a new push — useful right after reconnecting a broken connection, or if a push’s webhook never arrived.
With Wait for CI on, Deploy latest waits for the commit’s checks too. It answers “Deploying abc1234”, “Waiting for checks on abc1234”, or “Already deployed abc1234” when that commit is already live.
In the dashboard: open the linked target’s settings (the same Deploy from GitHub/GitLab editor) and click Deploy latest.
With the CLI:
paas links deploy-latest -e production
Troubleshooting
Connecting fails. After GitHub or GitLab sends you back to the Integrations page, you may see:
- “Only an owner of the GitHub account or organization can connect it. If you are an owner, ask GitHub to approve the app’s updated permissions, then try again.”
- “Your GitHub account can’t access that installation.” — you don’t have access to the GitHub installation that was used; connect with an account that does, or pick a different installation.
- “This GitHub installation is already connected to another organization.” — each GitHub installation can only back one d2o Organization; disconnect it from the other one first, or install the app again for a different account/organization.
- “The connection took too long. Start again.” — the attempt expired before you finished on GitHub/GitLab’s side; click Connect GitHub/Connect GitLab again.
- “Couldn’t finish connecting. Try again.” — a generic failure; retrying usually works.
The connection shows “Broken”. Click Reconnect on it in Organization Settings → Integrations. Reconnecting repairs the connection in place — your repo links are kept, and nothing needs relinking.
A self-managed GitLab connection shows “Not finished”. The GitLab sign-in was started but never completed. Either Disconnect it, or click Reconnect: the form reopens with the host locked, and leaving Client ID and Client secret blank keeps the current ones.
A push doesn’t deploy. Check its state on the dashboard’s Deployments page or with paas links get,
or on the Environment’s Deployments page under “Pushes not yet deployed” (it lists pushes
waiting for checks and ones that failed checks, timed out or couldn’t be fetched; replaced pushes,
and failures already followed by a successful deployment, aren’t listed). If two pushes land
close together, only the later commit deploys — that’s expected, not a failure. With Wait for
CI on, the “Last push” line shows why, and on GitHub so does the commit’s check:
- “Not deployed: checks failed (…)” — one of the commit’s checks failed; fix it and push again, or, if the check was flaky, re-run it and then use Deploy latest to re-check the same commit without a new push.
- “Not deployed: checks were still running after 2 hours” — the checks never finished in time; this also covers a GitLab manual job nobody ran — run it, then push again or use Deploy latest.
- “No app in this environment builds from its shared source, so this push wasn’t deployed” — the Environment is linked, but none of its apps builds from the shared remote. Link the repository to the app instead (and unlink the Environment), or create an app that builds from the shared source.
Otherwise, try Deploy latest; if it keeps failing, reconnect the provider in Integrations.
Unlinking: click Unlink on the linked target’s settings and confirm Unlink repository, or
run paas links unset -e production. Direct pushes are allowed again immediately.
Follow a requested deployment
Deploy latest opens the deployment request immediately. You can see whether it is waiting for checks or importing the commit. When the deployment starts, the page opens its numbered details automatically. If that commit is already deployed, it opens the existing deployment instead.
Failed checks, timeouts, import errors and requests replaced by a newer request remain visible with their reason. The request keeps the repository and branch it started from, even if you later change the settings.
While direct pushes are blocked, the deployment-token page keeps existing tokens visible so you can revoke them. It hides token creation until you enable direct pushes.
Pipelines, Promotion & Rollback
A Project is a pipeline: its Environments are stages, ordered development → staging → production. Promotion moves the image an app already built in one stage into the next stage, without rebuilding it. This page explains how a stage gets its images, how to promote and roll back, and what the platform refuses and why.
Build stages and promote-only stages
Every Environment has a release source:
| Release source | How apps in the Environment get their image |
|---|---|
| Builds from source (default) | The platform watches the Environment’s git branch, builds a new image on every push, and deploys it (subject to Automatic deployment). |
| Promote-only | Nothing is built here. An app’s image is whatever was last promoted into it (or rolled back to). Pushes to this Environment’s git remote are refused, and so is a manual deployment. |
New Environments always start as Builds from source. Switch an Environment to promote-only in the dashboard under the Environment’s Settings → General → Release source, or from the CLI:
paas envs update production -o acme -p shop --release-source promote
A stage is either one or the other, never both. The usual shape is one build stage (development) feeding one or more promote-only stages (staging, production).
What the switch does
Switching to promote-only removes the Environment’s build machinery. It is refused with UNPINNED_APPS if any app in the Environment has never been built or promoted — such an app would have no image to run. Build or promote it first, then switch.
Switching back to builds from source recreates the build machinery: the platform rebuilds every app from the branch and deploys the new builds, replacing whatever had been promoted. The dashboard asks you to confirm before it does this.
Creating an app in a promote-only stage
You can create an app in a promote-only Environment as long as an app with the same name exists in the stage upstream. The new app starts with the upstream app’s current image. If there is no upstream image yet, the app is created but nothing runs until the first promotion; the pipeline board shows it as awaiting first promotion.
The pipeline board
Open a Project’s Pipeline tab. The Project Overview lists its environments. Application cards line up across environments; a placeholder shows when an application is missing.
Databases and Object storage have separate sections with their status and configured size, version or storage tier. They link to resource details and are not promoted. Build stages show Built here or Awaiting first build instead of comparing themselves with an upstream stage.
The Pipeline page shows one column per stage. Each app card shows the running image’s short digest and, where the platform knows it, the commit it was built from, plus one of:
| Card says | Meaning |
|---|---|
| not built yet | A build stage whose app has not produced an image yet. |
| awaiting first promotion | A promote-only stage whose app has never received an image. |
| not in upstream | No app with this name exists in the stage before this one, so there is nothing to promote from. |
| in sync with upstream | Running the same image as the same-named app upstream. |
| differs from upstream | Upstream has moved on (or this stage was rolled back). |
Promoting
Promotion copies the image digest from the same-named app in the source stage into the target stage. Databases, buckets, secrets and environment variables are per-Environment and are never copied.
You need the Promote & roll back capability on the target Environment and View on the source. The target must be promote-only; promoting into a build stage is refused with BUILD_ENV.
From the dashboard: each stage column has a Promote all apps to next button that promotes every app present in both stages. For one app, open its card’s actions menu and choose Promote to next. If the stage’s button is disabled, the reason shows under it (“production builds from source — set its release source to promote-only first”, or “Requires promote on production”), and the per-card buttons are hidden.
From the CLI:
# one app
paas promote web --from staging --to production -o acme -p shop
# every app that exists in both stages
paas promote --all --from staging --to production -o acme -p shop
The result is a table with one row per app:
| Outcome | Codes | Meaning |
|---|---|---|
| promoted | — | The image was copied. A HISTORY_WRITE_FAILED code on a promoted row means the image shipped but its history entry could not be written. |
| skipped | ALREADY_CURRENT | The target already runs this image. |
| skipped | NOT_IN_TARGET, NO_UPSTREAM_IMAGE | No same-named app in the target, or the source app has no image yet. |
| error | VALUES_MISSING, PROMOTE_FAILED | The target app cannot be promoted (its framework produces nothing deployable) or the write failed; the row’s message says more. |
The CLI exits 1 when any row errored, or when you named a single app and it was skipped for any reason other than ALREADY_CURRENT. paas promote --all with everything already current exits 0.
Protected targets
Promoting into a protected Environment asks you to type the Environment’s name to confirm. The CLI does the same, or skips the prompt with --yes; without a terminal and without --yes it refuses before sending anything. Every promotion is recorded in the Organization’s audit log.
Rolling back
On a promote-only stage you can re-pin an app to any earlier image it has run in that Environment. Rolling back needs the Promote & roll back capability on that Environment.
From the dashboard: open the app and click Rollback… in its header. The picker lists this app’s earlier releases, newest first, from its last 100 deployments, excluding the one currently running.
From the CLI: find the deployment id (id) with paas releases --json -a web, then:
paas releases --json -a web -e production -o acme -p shop
paas rollback web --to 6f1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c00 -e production -o acme -p shop
Rollback is refused with BUILD_ENV on a build stage (push a fix or re-deploy instead), with ALREADY_CURRENT when the app already runs that image, and with a 404 when the id belongs to another app.
This re-pins to any earlier version this app has run, healthy or not. To roll back a deployment that failed its health check, in any Environment, use paas deploys rollback instead — see Deployments, Health Checks & Rollback.
What a promote-only stage refuses
| Action | Response |
|---|---|
git push to the Environment’s remote (SSH or HTTPS) | Rejected with a message naming the upstream stage to push to and promote from. |
paas deploy / resource sync | 409 PROMOTE_ONLY_ENV. Re-syncing the whole Environment is still allowed. |
| Asking for the Environment’s git remote | 409 PROMOTE_ONLY_ENV. |
| Promote or rollback into a build stage | 409 BUILD_ENV. |
Reading the history
Deployment history marks each entry as a build, a promote or a rollback — promote entries read “promoted from source by name”. paas deploys list --json includes the same kind and each deployment’s id, which is the argument paas rollback --to takes.
Next steps
- Deployments, Health Checks & Rollback
- Organizations, Projects & Environments
- Access Control
- CLI Command Reference
Checking dependencies before promotion
Before promotion, d2o checks the selected applications’ database and object-storage attachments and known connections to other applications. A target service can have a different name if the application has the same compatible binding there.
If dependencies are missing, review the warning and explicitly confirm before continuing. A protected environment also requires its name in the same confirmation. If the dependency warnings change before the request is applied, you must review them again.
If the CLI reports missing dependencies, attach the required services first or review and confirm the promotion in the dashboard.
This check covers known attachments and application connections; it cannot infer dependencies hidden in secrets or external services. Promotion only moves application images. It does not create dependent services or copy databases, objects, secrets or environment-variable values.
Restarting an App
A restart replaces the running instances of a Web Application or Background Worker with fresh ones. It does not rebuild your app, pull new code, or change any configuration — the same release comes back up, just in new instances.
What a rolling restart does
Restarts are rolling: the platform starts a replacement instance and stops an old one only once the replacement is ready, so with a Health Check Path set the app keeps serving throughout — even with a single instance. Without a health check, the rollout only requires the replacement to stay running for 10 seconds (web apps) or 30 seconds (workers); this does not prove the app can serve requests. See Health checks. A singleton app is the exception: its old instance stops before the new one starts, so it is briefly down.
When you need one
Most changes on the platform roll out on their own — a git push triggers a build and deployment, and a deployment already brings up fresh instances. Reach for a restart when the instances themselves are the problem:
- Picking up a deleted and re-created secret. Binding a secret or changing its value already rolls out new instances. Creating a replacement for a deleted secret does not; restart if an instance started while that secret was absent. See the secret lifecycle.
- Clearing bad in-memory state. A wedged connection pool, a memory leak, a stuck background job — a restart gives every instance a clean start.
A restart will not fix a broken build or bad code. If the app is failing because of what was deployed, fix the code and push again, or roll forward with a new deployment — see Deployments, Health Checks & Rollback.
Restart from the dashboard
- In the dashboard, open your Organization, then the Project, then the Environment (for example
acme→shop→production). - Open the app (for example
web). - Click Restart and confirm.
Restarting requires deploy access. A stopped app (scaled to 0) can’t be restarted — scale it up instead. An app blocked for going over its monthly transfer allocation can’t be restarted either.
The app’s instance list updates as old instances are replaced by new ones.
Restart from the CLI
paas restart restarts the app you point it at with the usual context flags:
paas restart -o acme -p shop -e production -a web
Options:
-o, --org,-p, --project,-e, --env,-a, --app— select the app to restart--json— print the raw API response instead of a table--host <HOST>— target a specific platform address (overridesPAAS_API_URLand the configured current host)
Watch the restart complete by listing the app’s instances:
paas ps -o acme -p shop -e production -a web
or check the app’s deployment status:
paas status -o acme -p shop -e production -a web
Restart vs. deploy
- Restart (
paas restart): fresh instances of the current release. Use it to re-read secrets or clear state. - Deploy (
paas deploy): triggers a deployment of the app, applying whatever is currently configured. Use it when you changed what should be running, not just how it’s running.
Next steps
- Logs — confirm the app came back healthy after a restart
- Managing Secrets — automatic rollout and the re-created-secret exception
- Deployments, Health Checks & Rollback — when a restart isn’t enough
- Scaling & Autoscaling — run more instances, spread across servers
Logs
Every Web Application and Background Worker has a log view: a live stream while the app is running, and a snapshot of the most recent lines when it is not. Reading the log snapshot requires view access; streaming logs live (the dashboard’s live view and paas logs --tail) requires Deploy & operate or Promote & roll back — see Access Control.
In the dashboard
Open your app’s Resource page and go to the Logs tab.
While the app is running, logs stream in live automatically — the footer shows a Live indicator, and the Pause button stops the stream and becomes Stream to resume it. If the stream is interrupted (for example, an instance restarts), it reconnects on its own and continues from the new instance.
The viewer also gives you:
- Level filter — narrow the view to Errors, Warnings, Info, or Debug. Levels are detected from the text of each line, so they are best-effort.
- Auto-scroll — the view stays pinned to the newest line. Scroll up to read something older and auto-scroll pauses so new lines don’t pull you back down; the Jump to latest button re-pins it.
- Line buffer — the live view keeps roughly the last 1,000 lines.
- Instance filter — when the app runs more than one instance, each line shows which instance wrote it, and the Instance menu narrows the view to one of them (or All instances).
When the app is not running — it crashed, failed to boot, or was stopped — the live stream can’t open. The Logs tab falls back to a snapshot of the last log lines, marked Snapshot — resource not running, which is exactly what you want for a post-mortem.
Snapshot logs with the CLI
paas logs prints a snapshot of recent log lines. Select the app with the usual scope flags:
paas logs -o acme -p shop -e production -a web
Add --json to get the raw API response instead of plain log lines. The remaining examples assume the same -o/-p/-e scope flags.
Live streaming with the CLI
Add --tail to follow logs live:
paas logs -a web --tail
The stream follows every running instance of the app, starting from each one’s last 200 lines, and picks up new instances as they start; once more than one instance has logged, each line is prefixed with [instance]. Pass --instance to follow just one. Combining --tail with --container, -n, or --json is a usage error. Press Ctrl+C to stop.
Viewing one instance
By default the snapshot and the stream show every running instance. To see just one, list the app’s instances first:
paas ps -a web
Then pass an instance name to --instance:
paas logs -a web --instance web-6f7c9d4b8-xkq2n -n 200
-n, --num (default 500) controls how many lines the snapshot returns, and --container narrows it within an instance — most apps never need it. These two work only with snapshots; --instance works with both.
While a build is in progress
Right after a deployment there may be no app logs yet, because your new image is still being built. Instead of failing with a bare “no logs” error, the log stream reports the build state:
- the build hasn’t started yet,
- the image is still building,
- the build failed (with the build’s error message),
- or the image is built but no instance is running yet (rollout pending).
Once the first instance is running, logs flow as usual. To check build and deploymentment status, use paas releases — it lists the environment’s recent deployments and their states — or the deployment history in the dashboard, which refreshes automatically — see Deployments, Health Checks & Rollback.
To read a finished build’s output, open the deployment in the dashboard (its Logs section), or run paas logs --build 42 (add --release for the release command’s log, -a web when the deployment built several apps). Build logs are kept for the Environment’s last 1,000 deployments.
Next steps
- Console & One-off Commands — open a shell in a running instance
- Deployments, Health Checks & Rollback — track builds and releases
- Restarting an App — roll all instances when logs show a wedged process
- Troubleshooting & FAQ
Console & One-off Commands
Sometimes you need to get inside a running app: poke at the filesystem, open a framework console, or run a migration script. The platform gives you three ways in, for Web Applications and Background Workers:
- an interactive Console in the dashboard,
paas execfor an interactive terminal from your own shell,paas runfor one-off, non-interactive commands.
Consoles are only available for resources that run your code. Databases and Object Storage buckets have no console — manage databases through attachments and buckets through the Files tab or paas buckets.
To open a console or run a command you need the deploy permission on the project, and the app needs at least one running instance — the dashboard’s Connect button is only enabled while the app’s status is running. See Access Control.
List running instances
Each app runs as one or more instances (set by its replica count or autoscaling). paas ps lists them:
$ paas ps -o acme -p shop -e production -a web
INSTANCE PHASE READY CONTAINERS
web-7d9f6c5b48-2xqkm Running true web
If no instance is ready because a build is still in progress (or a build failed), paas ps prints the reason below the table. Add --json for the raw API response.
Inside a repository with a d2o (Environment) or paas (app) git remote, the CLI infers -o/-p/-e from it — and -a from the paas remote, or from the directory you’re in for apps built from the Environment’s remote — so a bare paas ps works. Elsewhere, pass the flags, or set defaults with paas orgs set-default and paas projects set-default (environment and app must always come from the flag or the remote).
Console in the dashboard
- In the dashboard, open the environment and click the app.
- Open the Console tab.
- Pick an instance from the dropdown — the first ready one is pre-selected.
- Click Connect.
You get a shell (/bin/sh by default — change the command field before connecting if you want something else, like a framework console). Run commands as you would in any terminal; press Ctrl+D or click Disconnect to end the session.
The session stays alive if you switch to another tab of the same app — for example to glance at Logs — and is still connected when you come back.
If the tab shows “Resource must be running to connect to console”, the app has no ready instance yet; check its status on the Overview tab and its deployments.
Interactive terminal with paas exec
paas exec opens the same kind of session from your own terminal:
paas exec -o acme -p shop -e production -a web
You land in /bin/sh on a running instance. Exit with exit or Ctrl+D.
Options:
--command <COMMAND>— start something other than/bin/sh.--instance <INSTANCE>— connect to a specific instance, by the name shown inpaas ps. Without it, the first running instance is used.--container <CONTAINER>— only needed if an instance runs more than one named process; normally leave it out.
paas exec requires a real terminal: it refuses to start when stdin/stdout is not a TTY (a script, CI), and --json is not supported — there is nothing to render as JSON in an interactive session. For scripted work, use paas run.
One-off commands with paas run
paas run executes a single command on a running instance and prints its output. The -- before the command is required, so its flags aren’t parsed as paas run’s own:
paas run -o acme -p shop -e production -a web -- ls -la
paas run -a web -- ./bin/rails db:migrate
The command runs without an interactive terminal, so it must not expect input. paas run prints the command’s standard output; its error output is shown only when it printed nothing else (and paas run then exits 1). The command’s own exit status is not relayed, so check the output to see whether it worked. --instance and --container work the same as for paas exec, and --json returns the raw API response.
Next steps
- Logs — snapshot and live-streamed app output
- Restarting an App — roll all instances without a deployment
- Scaling & Autoscaling — control how many instances run
- Command Reference — every
paascommand and flag
Metrics
Every Web Application and Background Worker shows live resource usage in the dashboard, so you can see what your app is actually consuming without leaving the page you deploy from.
What is measured
- CPU — processor usage in cores, summed across all of the app’s running instances.
- Memory — working-set memory in bytes, summed across all running instances.
- Throughput (Web Applications only) — HTTP requests per second arriving at the app’s public endpoint.
- p95 latency (Web Applications only) — the response time that 95% of requests beat, in milliseconds.
Throughput and latency are measured at the platform’s edge, so they reflect real traffic to your app’s URL. They only appear for Web Applications, and only once the app has received some traffic — a Background Worker has no public endpoint, so it shows CPU and memory alone.
Where to find them
- In the dashboard, open your Organization, then the Project and Environment (for example acme → shop → production).
- Click the app — say web.
- You land on the Overview tab. The usage panel sits inside the Deployment Status card at the top of the page.
Each metric shows its latest value with a small trend sparkline next to it. If the app has CPU or memory limits configured, the panel also shows usage against the limit — for example 128Mi / 256Mi · 50% — with a bar that changes color as you approach the limit. That makes it easy to spot an app that needs a bigger memory allowance or more instances before it becomes a problem.
For a longer view, open the app’s Usage tab: Live metrics shows the same meters over the last 1 hour, 6 hours or 24 hours (pick the range at the top of the card), next to the app’s bandwidth this month.
Time range and freshness
The Overview panel shows the last hour of data at roughly one-minute resolution (the Usage tab’s 6-hour and 24-hour ranges use 5- and 15-minute steps). While the page is open it refreshes automatically every 30 seconds, so you can watch the effect of a deployment, a scale change, or a traffic spike as it happens.
When metrics are missing
- “Resource metrics unavailable” — the installation you are using has no metrics backend configured. Ask your platform operator.
- “No recent samples” — the panel is working but has nothing to show yet. A freshly deployed app takes a short while to produce its first samples, and a stopped app (zero instances) produces none.
- No throughput or latency meters — either the resource is a Background Worker, or the Web Application has not yet received any HTTP traffic.
A metric that temporarily fails to load shows as an empty series rather than an error; the rest of the panel keeps working.
Metrics and the CLI
There is no paas command for usage metrics today — they live in the dashboard. From the CLI you can still check on a running app:
paas status -o acme -p shop -e production -a web # deployment status and health
paas ps -o acme -p shop -e production -a web # list the app's running instances
paas logs -o acme -p shop -e production -a web --tail
Using metrics to act
- Memory climbing toward its limit on every instance: raise the app’s memory limit or add instances — see Scaling & Autoscaling.
- CPU pinned high while p95 latency grows: add instances, or turn on autoscaling so the platform does it for you.
- Throughput at zero when you expect traffic: check Logs and the app’s public URL on its Overview tab.
Next steps
- Scaling & Autoscaling — act on what the meters show
- Logs — pair usage numbers with what the app is printing
- Deployments, Health Checks & Rollback — correlate a change in the graphs with a deployment
- Plans, Limits & Usage — how per-app limits are set
Custom Domains & TLS
Every Web Application gets a generated address on the platform the moment it is created. When you are ready to serve traffic on your own domain, you attach one — and prove that it is yours. Once ownership is proved, the platform routes the domain to your app and issues a TLS certificate for it automatically.
Custom domains apply to Web Applications only. Background Workers and Databases are not reachable over HTTP and have no domains to attach.
The default app URL
Each Web Application is assigned a generated hostname — a random subdomain under the platform’s app domain — with HTTPS enabled from the start. You’ll find it on the app’s page in the dashboard and on its Domains tab, under Platform Endpoint.
This address always works, TLS included, and it keeps working after you add custom domains: custom domains are aliases for it, not replacements. That makes the platform endpoint a reliable fallback for health checks and for verifying a deployment while a new domain is still being set up.
The flow, end to end
Attaching a domain takes four steps:
- Add the domain to your app. This creates a claim on the name. Nothing is routed and no certificate is requested yet.
- Create the ownership record — a TXT record at your DNS provider that carries a token unique to your organization and that domain.
- Verify. The platform looks the record up and, when it matches, marks the domain verified.
- Point the traffic record (CNAME, or ALIAS for a root domain) at your app’s platform endpoint. The certificate is issued automatically, and the domain goes live.
Steps 2 and 4 are two separate DNS records, and both are shown to you when you add the domain. You can create them at the same time — the order does not matter.
Add the domain
In the dashboard:
- Open your Web Application (for example the
webapp in theproductionenvironment of theshopproject) and go to the Domains tab. - Under Custom Domains, enter the hostname you want — for example
app.example.com— and click Add Domain. - The domain appears as Not verified, with both DNS records to create listed next to it.
With the CLI:
paas domains add app.example.com -o acme -p shop -e production -a web
paas domains add prints both records:
Added app.example.com to web. It is not served yet — the platform needs proof that you own it.
Create these DNS records at your DNS provider:
TYPE NAME VALUE
TXT _d2o-verify.app.example.com d2o-verify=0123456789abcdef0123456789abcdef
CNAME app.example.com k3j2h1.cx.d2o.io
Adding a domain requires the manage_web_apps permission on the project (organization Owners and Admins have it automatically). See Access Control.
The ownership record
The TXT record is the proof:
| Type | TXT |
| Name | _d2o-verify.<your domain> — for app.example.com, that is _d2o-verify.app.example.com |
| Value | d2o-verify=<token>, exactly as shown |
The token is unique to your organization and that one domain. Never share it: it is what proves the name is yours.
Some DNS providers want the name relative to the zone rather than fully qualified — in the example.com zone, enter _d2o-verify.app instead of _d2o-verify.app.example.com. If in doubt, create it and check with dig TXT _d2o-verify.app.example.com before verifying.
The traffic record
Point the domain itself at your app’s Platform Endpoint hostname, shown on the app’s Domains tab and printed by paas domains add:
- CNAME for a subdomain such as
app.example.com. - ALIAS for a root (apex) domain such as
example.com, where a CNAME is not allowed. Some DNS providers call this an ANAME record. If yours supports neither, serve the app from a subdomain such aswww.example.cominstead.
DNS changes can take anywhere from minutes to hours to propagate, depending on your provider and record TTLs.
Verify
Once the TXT record is live, verify the domain:
paas domains verify app.example.com -o acme -p shop -e production -a web
In the dashboard, click Check now next to the domain on the Domains tab.
If the record has not propagated yet, verification fails and says so — nothing is lost, just try again in a few minutes. The platform also re-checks unverified domains on its own in the background, so a domain you added and then forgot about will verify itself once its record shows up.
Unverified claims are not kept forever: a domain left unverified for 7 days is dropped, and you’ll have to add it again. That only affects unverified claims — a verified domain stays until you remove it. An organization can have at most 25 unverified domains at a time, and 50 custom domains in all; verify or remove one before adding more.
Statuses
Each domain is in one of four states. The Domains tab shows it as a badge, paas domains list prints it in words, and --json returns the raw value:
| Status | Badge | CLI | Meaning |
|---|---|---|---|
unverified | Not verified | not verified | No proof yet, and the platform has not looked for the record. The domain is not routed and has no certificate. |
verifying | Verified, setting up | verified, setting up | Ownership is proved and the certificate is being set up. It is issued automatically once the traffic record resolves. |
active | Active | active | The certificate is ready and the domain is serving traffic. |
error | Needs attention | error | The last ownership check failed (record missing, unreadable, or the name is already in use). The reason is shown next to the domain. |
A claim you have added but not yet proved usually reads error, not unverified: the platform re-checks unverified domains in the background, and once it has looked for a record that is not there yet, it records “We have not seen the verification record for <domain> yet.” against the claim. That is expected — create the TXT record and verify.
paas domains list -o acme -p shop -e production -a web
DOMAIN STATUS ADDED
app.example.com verified, setting up 2026-09-20
shop.example.com error 2026-09-22
shop.example.com: We have not seen the verification record for shop.example.com yet.
A domain marked "not verified" is not served: create its ownership record, then
run `paas domains verify <domain>`. "Verified, setting up" means the certificate
is still on its way.
Every hostname is verified on its own
Proving example.com does not cover www.example.com, api.example.com, or any other subdomain. Each hostname you want to serve is added and verified separately, with its own TXT record. There is no wildcard support.
When a domain is already in use
A domain can be attached to only one app in your organization; adding it to a second app fails with “domain is already added to one of your apps” — remove it from the other app first.
Adding a domain that another organization has already verified fails:
That domain is already in use on the platform.
In the dashboard the same rejection offers a Release it button, which runs the release below for you. The platform will not say who holds it, and it will never hand a verified domain over automatically — not even to an organization that later proves ownership. This is deliberate: it is what stops someone from taking a live domain away from the app that is serving it.
Several organizations can each hold an unverified claim on the same name at once. Only the first to verify it gets it; the others stop being checked, show error with the same “already in use” message, and expire.
Releasing a domain you own
If a domain of yours is being served by an organization that should no longer have it — an old team account, a contractor’s organization, a project that moved — you can free it by proving you own the name:
paas domains release example.com -o acme
The platform asks for the same kind of TXT record, using your organization’s own token for that name. Create it, run the command again, and on success:
- the domain is detached from the app that was serving it, and stops routing there straight away;
- both organizations get an entry in their audit log;
- the owners of the organization that held it are emailed.
Releasing a domain does not attach it to your app. Once it is free, add it and verify it the normal way:
paas domains add example.com -o acme -p shop -e production -a web
paas domains verify example.com -o acme -p shop -e production -a web
If nobody is serving the domain, there is nothing to release — just add it.
In the dashboard you do not need the CLI for this: whenever a domain is refused because another organization holds it — when you add it, and when you choose Check now on a claim that lost the race — the Domains tab offers a Release it button that runs the same release and tells you which record to add if it is missing. Once it succeeds the name is yours to verify: released from the add form, the domain is added for you; released from a claim you already hold, choose Check now again.
Releasing needs organization Owner or Admin — see Access Control. Adding and verifying the freed domain afterwards needs manage_web_apps on the project, as usual.
Automatic certificate issuance
Once a domain is verified and its traffic record resolves to the platform, a TLS certificate is requested and issued automatically. There is nothing to upload and no renewal to manage.
Both halves are needed: the certificate authority checks the domain over the public internet, so it cannot be issued until the CNAME or ALIAS record is in place.
Remove a domain
On the Domains tab, click Remove next to the domain and confirm — or, with the CLI:
paas domains remove app.example.com -o acme -p shop -e production -a web
This detaches the domain from the app and removes its certificate and routing — requests to that hostname will no longer reach your app, and the name becomes available for anyone to claim again. The app’s platform endpoint is unaffected.
Removing a domain does not touch your DNS records; clean those up at your DNS provider separately.
Domains added before verification existed
Domains that were already attached when verification was introduced are treated as verified and keep serving traffic exactly as before — they read verifying / Verified, setting up until their certificate is ready, like any other proved domain. Their ownership record is still shown in the dashboard and the CLI, so you can create it at your leisure — worth doing, because it is what lets you re-verify the name later if it is ever released.
Troubleshooting
Verification keeps failing. Check the record with dig TXT _d2o-verify.<your domain> (or your provider’s DNS lookup tool). The answer must contain d2o-verify=<token> exactly as shown for that domain. The most common causes are a record created in the wrong zone, a provider that appended the zone name twice (_d2o-verify.app.example.com.example.com), and a TTL that has not expired yet.
Verified, but the domain does not load. The traffic record is the other half. Confirm dig app.example.com returns the app’s platform endpoint, then give issuance a few minutes. Until the certificate is ready the domain reads Verified, setting up (verifying in --json).
The status says error. The reason is shown next to the domain in the dashboard and under the table in paas domains list. Fix what it names and verify again.
Reserved names. Some names cannot be attached — platform-managed domains and a handful of names the operator has set aside. Adding one is rejected with a message saying so; use a domain you control.
Next steps
Move Settings Into Your Repo
Some app settings depend on the code: a health check path only makes sense
once your app answers it, a release command only makes sense once the
migration it runs exists. Moving those settings into a
d2o-paas.yaml file in your repository means
they travel with the version they belong to — a rollback restores the code
and the settings it needs together, instead of leaving an old version
running against a health check or release command meant for the version
after it.
This walks through moving a web app’s health check and release command, and a worker’s process, out of the dashboard and into the repository.
1. See what’s set today
paas apps info -o acme -p shop -e production -a web
This prints the app’s current settings, including its health check and
release command — whatever you’ve set in the dashboard or with
paas apps update up to now. Note down the values you want to move; you’ll
write the same ones into the file.
2. Write them into d2o-paas.yaml
Create d2o-paas.yaml at the root of your repository (or the app’s
--source-dir, if it has one):
release:
command: bin/migrate
health_check:
path: /health
apps:
worker:
process: worker
This example moves a top-level health check path and release command — both
shared by every app built from this repository — and gives the worker app
an explicit process, matching a worker: line in its Procfile. Your own file
only needs the fields you’re moving; see
d2o-paas.yaml Reference for every field and
how apps.<name> overrides the top-level defaults.
3. Check it before you push
paas manifest check -o acme -p shop -e production
$ paas manifest check -o acme -p shop -e production
d2o-paas.yaml is valid for production
This catches a typo’d field name or an out-of-range value before it reaches a build. See d2o-paas.yaml Reference for what an invalid file’s errors look like.
4. Push
git add d2o-paas.yaml
git commit -m "Move health check and release command into d2o-paas.yaml"
git push paas main
After the deployment
Once this version is live, the fields it sets show “from d2o-paas.yaml”
in the dashboard and can’t be edited there — paas apps update on one of
them fails with a message telling you to change it in the file instead. This
is expected: the file is now the source of truth for those fields, for as
long as a deployed version sets them.
Handing a field back
To give a field back to the dashboard or CLI, delete it from
d2o-paas.yaml and push. Once the new version with the field removed is
live, the dashboard and CLI setting applies again — including whatever value
it already held, so set it to what you want first if you plan to keep using
it.
Next steps
- d2o-paas.yaml Reference — every field, merging rules, errors and warnings
- Procfile — process selection and the release line
- Deployments, Health Checks & Rollback — health checks and the release command in full
Databases
A Database is a managed resource that lives inside an Environment, next to the apps that use it. The platform provisions it, keeps it running, and generates its credentials for you — you never install or operate the database engine yourself.
Four engines are available:
| Engine | Typical use |
|---|---|
| PostgreSQL | Relational data (general default) |
| Redis | Caching, queues, ephemeral data |
| MongoDB | Document data |
| MariaDB | Relational data |
Creating a database requires database-management permission in the project — see Access Control.
Create a database in the dashboard
- Open the Environment (for example
acme›shop›production) and click Add Resource. - Choose the Database resource type.
- Give it a name, such as
shop-db. Names use lowercase letters, numbers, and hyphens, and must be unique within the Environment. - Under Database Configuration, pick the Database Type: PostgreSQL, Redis, MariaDB, or MongoDB. Choosing PostgreSQL shows a High availability checkbox — see High availability.
- Set Size (GB): the size of its storage, 1 GB unless you change it. It can’t be changed after the database is created.
- Click Create Resource.
The dashboard creates the database with the platform’s default version. To choose the version yourself, use the CLI.
Create a database with the CLI
paas dbs create shop-db --type postgresql --storage-gb 20 \
-o acme -p shop -e production
Created database shop-db
Flags:
--type(required):postgresql,redis,mongodb, ormariadb.--storage-gb: size of the database’s storage volume in GB. If omitted, the database gets 1 GB. The size is fixed once the database is created.--version: engine version. Currently honored for MongoDB; PostgreSQL, Redis, and MariaDB run the platform’s standard version regardless of this flag.--ha: turn on high availability. PostgreSQL only.
When you run the CLI inside a repository that has a d2o (or paas) git remote, the
organization, project, and environment are inferred and you can drop the
-o/-p/-e flags. By default a database runs as a single instance; PostgreSQL
databases can opt into high availability — see below.
Size, CPU and memory
Every database counts against your plan’s capacity pools for its environment type, like an app — see Plans, Limits & Usage:
- Storage: the size you choose, or 1 GB when you don’t. It can’t be changed after creation, so pick a size with room to grow.
- CPU and memory: each database runs with up to 500m CPU (half a core) and 512 MiB of memory, and counts that much against your plan’s CPU and memory pools.
A database with high availability counts all three
twice. A create that doesn’t fit your plan is rejected with the usual
LIMIT_EXCEEDED error. The database’s Settings page shows its size, CPU
and memory.
Status lifecycle
A database moves through these statuses:
pending— just created; its configuration is being written.provisioning— the engine is being set up and is not yet ready to accept connections.running— the database is ready. The platform re-checks readiness continuously, so a database that becomes unhealthy drops back toprovisioninguntil it recovers.deleting— being torn down.
Check status from the CLI:
paas dbs list -o acme -p shop -e production
NAME ENGINE VERSION STATUS
shop-db postgresql - running
paas dbs info <name> shows one database in detail:
$ paas dbs info shop-db
shop-db (7c9e6679-7425-40de-963d-a1b2c3d4e5f6)
type: database
status: running
engine: postgresql
version: -
storage: 20 GB
created: 2026-08-31 14:02:11.482193 UTC
high availability: on
The high availability line only appears for PostgreSQL databases.
Add --json to either command for the raw API response.
High availability
PostgreSQL databases can opt into high availability. Off by default, it runs a second, continuously synchronized copy of your database on a different server. If the server running your database fails, the copy takes over automatically within seconds — connections drop briefly and reconnect, which most database drivers and connection pools already handle on their own. While the copy is healthy, writes wait for it to confirm before returning; if the copy is temporarily unavailable, your database keeps accepting writes on its own rather than blocking. Planned maintenance on our side no longer interrupts an HA database beyond a brief switchover.
Turn it on when creating a database — the dashboard’s High availability
checkbox, or --ha on the CLI:
paas dbs create shop-db --type postgresql --storage-gb 20 --ha \
-o acme -p shop -e production
You can also turn it on or off for a database you already have, with no change to its connection string or attachments:
-
Dashboard: open the database’s Settings page and use the High availability toggle.
-
CLI:
paas dbs update shop-db -o acme -p shop -e production --ha true
Enabling it on an existing database copies the data to the new standby in the background with no downtime; how long that takes depends on the database’s size. Disabling it removes the standby copy; the primary keeps running unaffected.
The database’s Overview page in the dashboard shows High availability:
On or Off, and paas dbs info shows the same as high availability: on
or off.
Cost: a high-availability database counts twice against your plan’s CPU,
memory, and storage pools, since it runs two full copies. A create or update
that would exceed your plan’s limits is rejected with the usual
LIMIT_EXCEEDED error — see Plans, Limits & Usage.
High availability is available for PostgreSQL only; Redis, MongoDB, and MariaDB databases always run as a single instance for now.
Credentials and connecting
You never copy passwords out of the dashboard. To use a database from an app,
you attach it: the platform injects a ready-made connection-string
environment variable (by default DATABASE_URL) into the app. See
Attaching a Database.
Backups
PostgreSQL and MariaDB databases are backed up automatically. Restoring from a backup requires contacting support. Redis and MongoDB are not backed up — treat Redis as ephemeral, and export MongoDB data yourself if you need copies. See Database Backups.
Delete a database
Deleting a database destroys its data permanently: the database stops, its storage is removed, and its backups are deleted within about an hour. There is no undo. In the CLI:
paas dbs delete shop-db -o acme -p shop -e production
Type "shop-db" to confirm deletion: shop-db
Deleted database shop-db
In scripts, skip the prompt by passing the name back explicitly. Without a
matching --confirm, a non-interactive session refuses to delete:
paas dbs delete shop-db -o acme -p shop -e production --confirm shop-db
If you create a new PostgreSQL database with the name of one you just deleted
in the same environment before its backups are gone, creation is refused with
BACKUPS_PENDING_REMOVAL — wait a little and try again, or pick another name.
Next steps
-
Durability and recovery — compare data stores and backup coverage
-
Network access — runtime connections and build restrictions
-
Attaching a Database — inject the connection string into an app
-
Database Backups — what is backed up and how to restore
-
Environment Variables — how injected configuration reaches your app
-
Plans, Limits & Usage — how many databases your plan allows, and how high availability counts against them
Attaching a Database
Attaching a Database to a Web Application or Background Worker injects a
connection-string environment variable into the app — DATABASE_URL by
default, or any name you choose. The platform wires the database credentials
directly into the variable, so you never copy a password or store the
connection string yourself.
Attachments work for all four engines: PostgreSQL, Redis, MongoDB, and MariaDB. If you haven’t created a Database yet, start with Databases.
The same card also attaches Object Storage buckets, which inject a set of S3 credential variables instead of a single connection string — see Object Storage Buckets.
How attachments work
-
The Database and the app must live in the same Environment. The dashboard only offers databases from the app’s environment as attach candidates.
-
Each attachment injects one environment variable holding a ready-to-use connection string for that engine:
Engine Connection string PostgreSQL postgresql://…Redis redis://default:<password>@<host>:6379MongoDB mongodb://…(the standard connection string)MariaDB mysql://<user>:<password>@<host>:<port>/<database> -
The variable is managed by the platform. It does not appear in the app’s editable environment-variable list, and detaching removes it.
-
Attaching and detaching require the database-management permission on the project. Organization Owners and Admins have it; other members get it through group permissions.
Attach in the dashboard
- Open your app — for example Organization
acme→ Projectshop→ Environmentproduction→ thewebapplication. - Go to the Config tab. Below the environment variables you’ll find the Attached Resources card.
- Pick a database from the dropdown — say
shop-db (PostgreSQL). - Optionally type a variable name. Leave it blank to use
DATABASE_URL. - Click Attach.
The attachment appears in the list with the variable name next to the database. The platform updates the app’s deployment configuration so its instances receive the variable.
Choosing the variable name
The default DATABASE_URL is fine when an app uses a single database. When
you attach more than one — a PostgreSQL database and a Redis cache, for
instance — give each attachment a distinct name so your code can tell them
apart, such as DATABASE_URL and REDIS_URL. Names are uppercased
automatically as you type.
Attach from the CLI
With CLI 0.9.0 or later, select the app and database explicitly:
paas apps attach web shop-db -o acme -p shop -e production
paas apps attach worker shop-db --env-var REPORTING_DB_URL -o acme -p shop -e production
paas apps attachments web -o acme -p shop -e production
--env-var defaults to DATABASE_URL. The attachment list shows the ID used for detaching, without displaying credentials. To connect Redis alongside the primary database, attach it under --env-var REDIS_URL. See app attachment commands.
Older CLIs support paas attach shop-db -a web and --as REPORTING_DB_URL; these aliases remain available. Their detach command takes a resource name and optional --as, whereas paas apps detach takes an attachment ID.
Verify from your app
After the app rolls out, check that it received the variable without printing the connection string:
paas run -o acme -p shop -e production -a web -- sh -c 'if [ -n "$DATABASE_URL" ]; then echo "DATABASE_URL is set"; else echo "DATABASE_URL is missing"; exit 1; fi'
Look for the explicit confirmation; the one-off command guide explains remote exit-status limitations. Presence alone does not prove connectivity. Exercise a database-backed app endpoint, or run your framework’s connection check and report only success or a redacted error. See network access if the connection times out.
Detaching
In Attached Resources, click Detach, or find the attachment ID and remove it:
paas apps attachments web -o acme -p shop -e production
paas apps detach web ATTACHMENT_ID -o acme -p shop -e production
Replace ATTACHMENT_ID with the ID from the list. Detaching removes that injected variable and rolls out the app; it does not delete the database. Update code that depends on the connection first. Deleting a database is a separate, destructive operation; review backup and deletion behavior.
Next steps
- Databases — create and manage database resources
- Database Backups — what’s backed up for PostgreSQL and MariaDB
- Environment Variables — app configuration you manage yourself
- Access Control — who can manage databases
Database Backups
The platform backs up PostgreSQL and MariaDB databases automatically. There is nothing to enable or configure — backups are set up automatically as soon as the database is created.
What is backed up
- PostgreSQL — a full backup is taken as soon as the database is created, then every night, and every change in between is archived continuously.
- MariaDB — a full backup every night.
Backups are kept for 14 days. They are stored on the platform’s own storage, separate from the database itself, so they survive problems with the database’s instances.
Deleting a database deletes its backups too, usually within an hour — there is nothing to restore afterwards.
You don’t manage backups yourself: there is no backup command in the CLI and no backup panel in the dashboard. If you want to confirm which databases you have and which engine each one runs, list them:
paas dbs list -o acme -p shop -e production
What is not backed up
Redis and MongoDB databases are not backed up today.
- Treat Redis as disposable — a cache or queue whose contents you can afford to lose.
- For MongoDB, don’t keep data you can’t recreate without making your own copies. If you need an export, take one yourself from your application (for example with a one-off command — see Console & One-off Commands).
Durability and recovery
| Storage | Survives app replacement? | Platform recovery protection |
|---|---|---|
| App filesystem or process memory | No guarantee; not shared across replicas | No backup; use only for temporary or rebuildable data. |
| PostgreSQL | Yes | Continuous change archiving and nightly backups, retained 14 days; optional HA protects against instance/server failure. |
| MariaDB | Yes | Nightly backups, retained 14 days; single instance. |
| MongoDB | Yes | Single instance; no platform backups. Arrange your own copies for data you cannot recreate. |
| Redis | Yes, while Redis remains available | Single instance; no platform backups. Cache and queue contents can be lost with Redis. |
| Capacity bucket | Yes | Single-node storage; protects against disk failure, not whole-node loss. No platform backups. |
| High-availability bucket | Yes | Replication protects against node loss; it is not a recovery mechanism for accidental deletion or overwriting. |
High availability and backups solve different problems. Replication can replicate an accidental deletion too. Keep independent copies of irreplaceable objects; the platform does not offer an object-restore workflow. See bucket tiers.
Before deleting a database or Environment, export anything you need and verify the copy. Database deletion also deletes its platform backups; Environment deletion removes all its resources. A code rollback never restores database contents.
Restoring from a backup
Restores are not self-serve. To restore a database, contact your platform’s support. There is no restore button in the dashboard and no restore command in the CLI.
When you get in touch, include:
- the Organization, Project, and Environment (for example
acme/shop/production), - the database’s name (for example
shop-db), - the point in time you want to get back to — PostgreSQL can be restored to a point in time within the last 14 days; MariaDB is restored from the nearest nightly backup.
Next steps
- Databases — creating and managing database Resources
- Attaching a Database — wiring a database to an app
- Object Storage Buckets — durable file storage
- Troubleshooting & FAQ
Object Storage Buckets
An Object Storage resource is an S3-compatible bucket for files your app
stores and serves — uploads, images, exports, static assets. Like every
resource, a bucket lives inside an Environment. You work with it three ways:
a Files browser in the dashboard, the paas buckets CLI commands, and any
standard S3 client using credentials the CLI hands you.
Creating and managing buckets requires the object-storage management permission on the project. Organization Owners and Admins have it; other members get it through group permissions.
Create a bucket
- Open the Environment — for example Organization
acme→ Projectshop→ Environmentstaging— and click Add Resource. - Choose Object Storage, and give it a name, such as
media. The platform’s S3 bucket name combines the environment and this name and must fit in 63 characters, so a very long name is refused with the longest one that fits. - Pick a Storage tier:
- Capacity (the default) — large, low-cost, single-node storage. Durable against disk failure, but not against a whole node being lost, and not backed up. A good fit for assets you can regenerate or re-upload.
- High availability — replicated storage that survives node loss. Use it when service must survive node loss. Replication does not recover accidental deletion; review durability and recovery and keep independent copies of irreplaceable files. The tier decides which of your plan’s two object-storage pools the bucket’s quota counts toward (see Plans, Limits & Usage). You aren’t locked into this choice — see Moving a bucket to a different tier below.
- Optionally set a Bucket Quota (GB) — a cap on the total data the bucket can hold. Leave it blank for the 10 GB default.
- Click Create Resource.
Provisioning takes a moment; until it finishes, the bucket’s pages show
“Bucket is still being provisioned.” The underlying S3 bucket name is
generated by the platform — you’ll find it on the resource’s Overview tab
or via paas buckets info.
The High availability tier may not yet be offered in Production-type Environments on your installation, and either tier can be unavailable; creating one fails with a clear error.
From the CLI, paas buckets create does the same thing:
paas buckets create media -o acme -p shop -e staging --tier ha
paas buckets list -o acme -p shop -e staging
paas buckets delete media -o acme -p shop -e staging --confirm media
--tier capacity|ha picks the starting tier, same as the dashboard’s
Storage tier field (default capacity) — see Moving a bucket to a
different tier to change it later;
--quota-gb is the same as the
dashboard’s Bucket Quota (GB) field. --public makes the bucket public
immediately instead of leaving it private until you flip visibility
separately — for an ha bucket, whose storage isn’t ready the instant it’s
created, the CLI retries for a few seconds and tells you to run
paas buckets visibility public by hand if it’s still not ready. paas buckets delete asks you to type the name to confirm; pass --confirm <NAME>
in scripts.
Moving a bucket to a different tier
The tier you pick at creation isn’t permanent. Move a bucket between Capacity and High availability at any time from its Settings tab — Change storage tier — or from the CLI:
paas buckets migrate media --tier ha -o acme -p shop -e staging
The CLI asks you to type the bucket’s name to confirm; pass --yes in a
script, and add --wait to have the CLI poll until the move finishes instead
of returning right away.
What happens. The move runs in the background:
- Objects are copied to the new tier while the bucket keeps serving normally.
- Near the end, writes to the bucket are paused for a few minutes (reads keep working) while the final copy is checked; uploads during that window are rejected, so retry them afterwards.
- The bucket switches over to the new tier, and any app attached to it restarts automatically to pick up the new connection details.
- A last check confirms the move.
The bucket’s name and public URL never change. The Overview and
Settings tabs show the move’s progress (copying, finishing up, switching
over, verifying) while it runs; paas buckets list/info show it as
<tier> -> <target tier> (<phase>) in the tier column. Capacity and High
availability draw from separate pools in your plan’s object-storage quota
(see Plans, Limits & Usage) — once a move
finishes, the bucket’s quota counts against its new tier’s pool instead of
its old one. A move is refused up front (the same “limit exceeded” error as
creating a bucket) if the bucket’s quota doesn’t fit in the target tier’s pool.
A bucket can’t be deleted, and a second move can’t be started, while one is
already in progress.
If a move fails, the bucket keeps serving from its current tier — nothing is lost. In the dashboard, an alert on the bucket’s Overview and Settings tabs offers a way to resolve it:
- If the move hadn’t switched over yet: Try again retries the same move,
or Keep on
<current tier>gives up and cleans up the partial copy. - If the move had already switched over and only the final check failed: Retry final check re-runs just that check — the bucket is already on the new tier.
From the CLI, re-run paas buckets migrate with the same --tier to retry,
or with the bucket’s current tier to give up (only possible before it has
switched over).
Browse files in the dashboard
The bucket’s Files tab is an object browser:
- Folders come from key prefixes — click a folder to descend, and use the breadcrumb trail to jump back up. Click Load more if a listing is truncated.
- Upload puts a file (one at a time) into the current folder. Uploads go straight to storage over a short-lived signed link.
- Download opens the object in a new tab, also via a short-lived link, so it works whether the bucket is public or private.
- Copy URL appears on each file when the bucket is public — it copies the object’s permanent public URL.
- Delete permanently removes the object after a confirmation.
Anyone who can view the project can browse and download; uploading and deleting require the object-storage management permission.
The CLI lists objects too, optionally under a prefix:
$ paas -o acme -p shop -e staging buckets ls -a media
KEY SIZE MODIFIED
avatars/ - -
banner.png 48213 2026-08-30T14:02:11Z
$ paas buckets ls avatars/ -a media
For bucket commands, -a names the Object Storage resource. If a listing has
more results than one page, the CLI prints a note (pagination is not yet
supported there — use the dashboard’s Load more).
paas buckets put and get move files the same way the dashboard’s
Upload and Download buttons do — straight between your machine and
storage, over a short-lived signed link:
$ paas buckets put ./banner.png -a media
Uploaded ./banner.png to banner.png (image/png)
$ paas buckets get banner.png ./local-banner.png -a media
Downloaded banner.png to ./local-banner.png
$ paas buckets rm avatars/old.png -a media
Deleted avatars/old.png
put guesses the object’s content type from the file’s extension (text/html,
image/png, and so on, falling back to application/octet-stream); pass
--content-type to set it explicitly, which matters most for a public
bucket serving a website — the wrong type can make a browser download an
.html file instead of rendering it. get’s destination defaults to the
key’s base name (banner.png for a key of avatars/banner.png); pass - to
write the object’s bytes straight to stdout instead of a file. A download
that fails partway never touches an existing file at the destination.
To upload into a folder, give put a key:
paas buckets put ./alice.png avatars/alice.png -a media stores it under that
key; without one, the key is the file’s name.
Public URL and visibility
Every bucket gets a generated public hostname of its own. Buckets start private: nothing is readable without credentials. Flip visibility on the resource’s Overview tab — Make public asks you to confirm, because anyone with the URL can then read every object in the bucket. Making a bucket public never allows writes, and it does not let anyone anonymously list the bucket’s contents — objects are fetched by their exact key.
While public, an object is served at the bucket URL plus its key:
<bucket-url>/avatars/alice.png. The Files tab’s Copy URL button
builds these links for you.
From the CLI:
$ paas -o acme -p shop -e staging buckets url -a media
https://<generated-name>.<storage-domain>
$ paas buckets visibility public -a media
public: true
Warning: anyone with the URL can read every object in this bucket.
$ paas buckets visibility private -a media
paas buckets url exits with an error if no public hostname has been
assigned yet. Visibility changes are recorded in the organization’s
audit log.
If the bucket (or its Organization, Project or Environment) goes over its
monthly transfer allocation,
public access is switched off until usage is back under budget: Visibility
reads Private — blocked, and the CLI shows
private (blocked; public once unblocked) — see
Troubleshooting.
S3 credentials via the CLI
To use your own S3 tooling — sync scripts, backups, an S3 GUI —
paas buckets creds prints the bucket’s credentials as shell export
statements (it requires the object-storage management permission, and each
read is audit-logged):
$ paas buckets creds -a media
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
export S3_ENDPOINT='https://...'
export S3_BUCKET='...'
export S3_REGION='us-east-1'
# eval "$(paas buckets creds -a media)" then use any S3 client with --endpoint-url "$S3_ENDPOINT"
Load them into your shell and point any S3 client at the endpoint:
eval "$(paas buckets creds -a media)"
aws s3 ls "s3://$S3_BUCKET" --endpoint-url "$S3_ENDPOINT"
The only --format supported is env (the default). Add --json to any
paas buckets command for machine-readable output, and use
paas buckets info -a media for a one-screen summary (availability, bucket
name, endpoint, region, tier, visibility, URL).
Attaching a bucket to an app
Attach a bucket to a Web Application or Background Worker in the same Environment from the app’s Config tab: the attach card lists both Databases and buckets (each type-labeled), and attaching a bucket requires the object-storage management permission. See Attaching a Database for the shared card — the same flow works for buckets, except the second field is a credential prefix rather than a variable name.
A bucket attached with no prefix — the app’s primary bucket — injects five SDK-standard environment variables, which most S3 client libraries pick up directly. Capacity buckets work with default client settings. HA buckets need path-style addressing (see Configuring your S3 client):
| Variable | Contents |
|---|---|
S3_ENDPOINT | The bucket’s S3 endpoint URL. |
S3_BUCKET | The bucket name. |
S3_REGION | The region to pass to S3 clients. |
AWS_ACCESS_KEY_ID | The access key for this bucket. |
AWS_SECRET_ACCESS_KEY | The secret key for this bucket. |
Configuring your S3 client
Path-style addressing for HA buckets. Capacity buckets accept both
forms, so SDK defaults work. HA buckets need the bucket in the path
(https://<endpoint>/<bucket>/<key>), not in the host name. Many AWS SDKs
default to the host-name form when the endpoint is a name, which fails with
“host not found” for HA buckets. Turn path-style on:
| Client | Setting |
|---|---|
| AWS SDK for JavaScript v3 | new S3Client({ endpoint, forcePathStyle: true }) |
| AWS SDK for Go v2 | s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true }) |
| boto3 (Python) | Config(s3={"addressing_style": "path"}) |
| AWS SDK for Rust | aws_sdk_s3::config::Builder::from(&cfg).force_path_style(true) |
| AWS SDK for Java v2 | S3Client.builder().forcePathStyle(true) |
| AWS CLI | addressing_style = path under s3 = in the config profile |
Checksum settings. An app with a capacity bucket attached also gets
AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED and
AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED. Capacity storage now
accepts every SDK’s default checksums, so these are only a compatibility
default. A value you set yourself (paas config set) takes precedence.
To attach a second bucket to the same app, give it a prefix (say
ASSETS): the five variables are then injected as ASSETS_ENDPOINT,
ASSETS_BUCKET, ASSETS_REGION, ASSETS_ACCESS_KEY_ID, and
ASSETS_SECRET_ACCESS_KEY, so they can’t collide with the primary bucket’s.
An app has at most one prefix-less bucket, and each bucket attaches once per
app.
Every attachment also injects a status link — STATUS_URL for the primary
bucket, <PREFIX>_STATUS_URL otherwise — a public, unauthenticated URL that
reports the bucket’s status and stays fixed for the bucket’s lifetime. It
returns {"state": "active" | "read_only", "tier": …} — read_only while
writes are paused during a tier move,
so your app can check it rather than fail uploads.
You can confirm the variables reached the app with a one-off command:
paas -o acme -p shop -e production -a web run -- printenv S3_ENDPOINT
Use CLI 0.9.0 or later to select the app explicitly. Omit --prefix for the primary bucket; use a distinct bucket and prefix for a second attachment:
paas apps attach web media -o acme -p shop -e production
paas apps attach web assets --prefix ASSETS -o acme -p shop -e production
paas apps attachments web -o acme -p shop -e production
paas apps detach web ATTACHMENT_ID -o acme -p shop -e production
Replace ATTACHMENT_ID with the ID from the attachment list. Detaching leaves the bucket intact. See app attachment commands and the older CLI aliases.
Next steps
- Attaching a Database — the same attachment model, for databases
- Managing Secrets — keep credentials out of your code
- Plans, Limits & Usage — how quotas count against your plan
- Access Control — who can manage object storage
Managing Secrets
Secrets hold sensitive values — API keys, passwords, tokens — that the platform stores encrypted and injects into your apps by reference. Their values are write-only: after you save a secret, nobody can read the value back through the dashboard or the API. You can only replace it.
Every secret has three parts:
- Name — uppercase letters, numbers, and underscores, starting with a letter and not ending with an underscore (e.g.
STRIPE_API_KEY). The dashboard normalizes what you type. Names are at most 253 characters. Names ending in_S3,_REDIS_CONN,_MONGODB_PASSWORDor_MARIADB_PASSWORDare reserved for the credentials the platform creates for your buckets and databases. The name is what apps reference; it need not match the app variable key. - Value — entered once, then hidden.
- Description — optional, shown in the secrets list so teammates know what the value is for.
Creating, editing, and deleting Project and Environment secrets requires the manage-secrets capability on the project (see Access Control). Organization secrets can only be managed by the Organization’s Owner and Admins. Secrets are managed in the dashboard; the CLI links apps to them but does not create them.
Choosing a scope
Secrets live at one of three levels:
- Organization — shared across the whole Organization.
- Project — shared by all of a Project’s Environments.
- Environment — specific to one Environment. This is the most common scope:
productioncredentials that staging must never see.
Pick the narrowest scope that works. A production database password belongs on the production Environment, not on the Organization.
Creating a secret
Each scope has its own secrets page in the dashboard:
- Environment — open the Environment’s Secrets page and click Add Secret.
- Project — open the Project’s settings and go to the Secrets tab (Project Secrets).
- Organization — open the Organization’s settings and find Organization Secrets.
In each case, enter a name, the value, and an optional description. The value field is masked as you type; each form has a Show toggle if you need to double-check it before saving.
Editing a secret
Environment secrets have an Edit action in the list; because values are write-only, its value field starts empty — enter a complete new value (there is no way to view or append to the old one), or leave it blank to keep the current value and change only the description, and save. Project and Organization secrets have no Edit action in the dashboard yet. To change one, send the new value to the API with PUT /api/v1/organizations/<org>/projects/<project>/secrets/<name> (Project) or PUT /api/v1/organizations/<org>/secrets/<name> (Organization) and a body of {"value": "<new value>"}; see API Access & Automation. The CLI can’t change secrets. In every scope, the name is fixed: to rename a secret, create a new one, move the links to it, then delete the old one.
This is also how you rotate a credential: edit the secret with the new value. The new value reaches your Environments within seconds, and every app that links to the secret restarts on its own to pick it up — a rolling restart, so a multi-instance app keeps serving traffic (a singleton app is stopped and started again, so it is briefly unavailable).
Deleting a secret
Click Delete on the secret and confirm. Deletion is permanent and cannot be undone. An Environment or Project secret that an app still links to can’t be deleted — remove those links first. An Organization secret can be deleted while apps link to it, and those links then lose their source, so update or remove them first.
Binding a secret to an app
A secret does nothing on its own — an app picks it up through an environment variable that references the secret instead of holding a literal value.
In the dashboard, open the app’s Environment Variables section, add a variable, and choose Link to secret, picking the secret from the list. The list shows the Environment’s secrets, then the Project’s, then the Organization’s. Organization secrets appear only for the Organization’s Owner and Admins. A name that a nearer scope also has appears once, in the nearer scope.
With the CLI, use paas config set --secret-ref with the secret’s name followed by the variable key to create on the app:
paas config set --secret-ref STRIPE_API_KEY STRIPE_API_KEY \
-o acme -p shop -e production -a web
The variable key doesn’t have to match the secret’s name — you can point PAYMENT_KEY at STRIPE_API_KEY. Both start with an uppercase letter. The secret must already exist in the Environment, the Project or the Organization. Only the Organization’s Owner and Admins can link an Organization secret. See secret resolution. Your app reads the result as an ordinary environment variable.
See Environment Variables for the full guide to per-app configuration.
When changes reach your app
Secret values are read when an instance starts. Use this lifecycle table to decide whether a manual restart is necessary:
| Change | What happens | Manual action |
|---|---|---|
| Create an unused secret | No app rollout | Bind it to an app variable when needed. |
| Add or remove a variable that references a secret | The app rolls out automatically | Wait for its replacement instances. |
| Replace an existing secret’s value | Linked apps restart automatically within seconds | Wait for the rollout. |
| Delete a referenced secret | Refused for an Environment or Project secret. An Organization secret is deleted; the reference has no source, and new instances may start without the variable | Remove or update the references first; validate required settings at startup. |
| Re-create a deleted Organization secret under the same name | Creating it does not restart apps | Restart apps that started while the secret was absent. |
For that last case:
paas restart -o acme -p shop -e production -a web
To rotate a Project or Organization secret, change its value as described in Editing a secret rather than deleting and re-creating it. See Restarting an App and startup validation.
Next steps
- Environment Variables — plain values, hidden values, and secret links on an app
- Secrets & Configuration — how configuration flows through the platform
- Restarting an App — picking up rotated values
- Access Control — who can manage secrets
Managing Your Organization
Organizations are the top level of the platform: every project, environment, and resource lives inside one. This page covers day-to-day organization administration — settings, members, roles, ownership, and deletion.
Organization settings live in the dashboard under Organization Settings, split into seven tabs: General, Members, Groups, Variables, Secrets, Integrations, and Usage.
Your personal organization
When you join the platform (by accepting an invitation), a personal organization is created for you automatically — one per user. It works like any other organization, with two differences: a personal organization cannot be deleted on its own (only by deleting your account), and its ownership cannot be transferred to anyone else. Its General tab has no Danger Zone.
Use your personal organization for experiments, and create a separate organization for anything a team works on together.
Creating an organization
In the dashboard, open Create Organization and fill in:
- Organization Name — the display name, editable later. Organization names must be unique on the platform (case-insensitive).
- URL Slug — 2–50 lowercase letters, digits and hyphens, starting with a letter and not ending in a hyphen; used in URLs (for example
/orgs/acme). It’s generated from the name as you type and can be edited before you create the org, but not afterwards. - Description — optional.
You become the organization’s owner. An account can create organizations while it owns fewer than three, not counting its personal one.
The CLI has an equivalent under paas orgs:
paas orgs list # organizations you belong to
paas orgs info -o acme # details for one organization
paas orgs create --help # see the create options
paas orgs update acme --name "Acme Inc"
paas orgs transfer acme --to new-owner@example.com
paas orgs delete acme --confirm acme
paas orgs set-default sets the organization the CLI falls back to when you don’t pass -o and you’re not inside a repository whose d2o (or older paas) git remote supplies one, and --json on any of these prints the raw API response for scripting.
General settings
The General tab lets you change the organization’s name and description. Edit the fields and click Save Changes.
Members and roles
The Members tab lists everyone in the organization with their role. Each member holds one of three roles:
| Role | What it can do |
|---|---|
| Owner | Full control, including deleting the organization and transferring ownership. Every organization has exactly one owner. |
| Admin | Manage members, groups, and projects. |
| Member | Access only through the groups they belong to — see Groups & Project Permissions. |
Owners and admins see an Invite Member button; new people join by email invitation — admins can invite Members, and only the owner can invite someone as an Admin. Pending invitations appear below the member list, where they can be revoked. Invitations are covered in detail in Inviting People.
Changing a member’s role
As the owner, each member row (other than your own) shows a role dropdown — switch it between Member and Admin and the change applies immediately. Other members see role badges only.
Removing a member
The owner can click Remove on a member row. The person loses access to the organization and its projects; you can re-invite them later. The owner cannot be removed — transfer ownership first.
Transferring ownership
An organization always has exactly one owner, and only the owner can hand that role to someone else. The new owner must already be a member; if necessary, invite them and wait for them to accept first. Personal organizations cannot be transferred.
The transfer takes effect immediately. The previous owner becomes an Admin, and all projects, environments, and resources stay in the organization.
In the dashboard
- Open Organization Settings → General and find Transfer ownership in the Danger Zone.
- Choose an existing member from New owner. The current owner is excluded.
- Click Transfer ownership and review the recipient and role change.
- Type the organization’s display name exactly as shown, then confirm the transfer.
Your role and the available organization controls update after the transfer succeeds.
With the CLI
Use the recipient’s email address or user UUID:
paas orgs transfer acme --to new-owner@example.com
paas orgs transfer acme --to 00000000-0000-0000-0000-000000000002
The organization slug is required explicitly. Email matching is case-insensitive and only searches existing organization members. A UUID must be the member’s user ID, not their membership record ID.
The CLI shows the recipient and role change, then asks you to type the organization’s slug. For scripts, provide the matching --confirm value; a non-interactive command without it is refused:
paas orgs transfer acme --to new-owner@example.com --confirm acme --json
--json prints the updated organization returned by the API. See the CLI command reference for the full syntax.
Through the API
The same operation is available at POST /api/v1/organizations/<org>/transfer with {"new_owner_id": "<user_id>"}. Get the recipient’s user_id from GET /api/v1/organizations/<org>/members.
Deleting an organization
Only the owner can delete an organization, and personal organizations cannot be deleted on their own.
In the General tab, the Danger Zone section holds the Delete Organization button. The confirmation dialog requires typing the organization’s name, because deletion is permanent: it removes the organization and every project, environment, and resource it contains. There is no undo — make sure you have copies of anything you need before confirming: application data, configuration, and the secret values you originally set (secret values cannot be read back from the platform).
Organization variables
The Variables tab holds plain environment variables that every app in the organization inherits. An environment or an app can override any of them by setting the same key. Owners and Admins can change them; every member can read them. See Organization and environment variables.
Integrations
Connect a GitHub or GitLab account so environments and apps can deploy from a repository — see Deploy from GitHub or GitLab.
Usage and limits
The Usage tab shows your organization’s plan limits and current usage. Limits apply when you create things — existing resources are never deleted because of a limit. See Plans, Limits & Usage.
Emails the owner receives
Platform emails about an organization go to its owner only; other members aren’t emailed. The owner is emailed when:
- a build fails (with a View build log link);
- a deployment fails, is rolled back, or its rollback fails;
- monthly bandwidth crosses 80%, 95% and 100% of the allocation;
- resources are blocked, or restored, for the monthly transfer allocation;
- a domain is released to another organization.
These emails are sent only if your installation has email configured.
Next steps
- Inviting People — organization and project invitations end to end.
- Groups & Project Permissions — give members access to specific projects.
- Access Control — how roles, groups, and capabilities fit together.
- Plans, Limits & Usage — what the Usage tab is telling you.
Groups & Project Permissions
Groups are how organization members get access to projects. An organization Owner or Admin can see and manage everything (except any capabilities your platform operator has reserved — see below); everyone else starts with no project access at all. You add people to a group, then grant that group a set of capabilities on each project it should be able to work with.
Group management lives in the dashboard. The paas CLI has no group
commands today.
Create a group
- In the dashboard, open your organization and go to Organization Settings → Groups.
- Click New Group.
- Enter a group name (required) and an optional description, then click Create group.
The new group opens immediately so you can add members and grant project access. Only organization Owners and Admins can create, manage, or delete groups.
Add members to a group
With the group open (click Manage next to it in the list):
- Use the Add a member… dropdown. It lists organization members who are not yet in the group.
- Pick a person and click Add member.
Remove someone with the Remove button next to their name. Removing them from the group takes away the project access the group granted; it does not remove them from the organization.
People must already be organization members before you can add them to a group — see Inviting People.
Grant a group access to a project
In the group’s Project Access section:
- Use the Grant a project… dropdown and click Grant access. New grants start at the Viewer preset (read-only).
- Adjust what the group can do with the capability selector: pick a preset, or click Advanced to toggle individual capabilities.
Revoke removes the group’s access to that project entirely.
Presets and capabilities
The five presets cover the common cases:
| Preset | Capabilities |
|---|---|
| Viewer | View |
| Release manager | View, Promote & roll back |
| Developer | View, Promote & roll back, Deploy & operate, Manage web apps, Manage workers |
| Maintainer | Developer, plus Manage databases, Manage object storage, Manage secrets |
| Admin | Everything, including Manage environments and Manage project |
Under Advanced, the individual capabilities are grouped into Read (View), Operate (Promote & roll back, Deploy & operate), and Manage — separate toggles for web apps, workers, databases, object storage, secrets, environments, and the project itself. Manage project also allows managing the project’s members and project invitations. Any combination is allowed; a mix that matches no preset shows as Custom.
Some capabilities may appear disabled and marked “Reserved to platform admins” — those are held back by your platform operator and withheld from everyone in the organization, including Owners and Admins; they cannot be granted to groups.
If someone is in several groups (or also has a direct project membership), their capabilities on a project are the union of everything granted.
Per-environment overrides
By default a grant applies to every Environment in the project. To give a
group different access in one environment — for example, full Developer
access in staging but Viewer-only in production:
- In the project’s access row, click Per-environment overrides.
- Next to the environment, click Override (inheriting project default). The override starts at the Viewer preset.
- Set the capabilities for that environment.
While an override exists, it is what the group gets in that environment instead of the project-level grant. Click Reset to inherit to remove the override and fall back to the project default.
Delete a group
Click Delete next to the group and confirm. Members lose the access this group granted, and the deletion cannot be undone.
Next steps
- Access Control — how roles, groups, and capabilities fit together
- Inviting People — bring new members into the organization first
- Managing Your Organization — organization roles, members, and settings
Inviting People
d2o PaaS is invitation-only. There is no self-serve signup: every account on the platform starts with an invitation. Members can send two kinds:
- Organization invitations make someone a member of your organization, with an organization role.
- Project invitations give someone access to a single project as a guest, with a set of project capabilities — without joining your organization at all.
Invitations are managed in the dashboard. The paas CLI has no invitation
commands.
Invite someone to your organization
You need to be the organization’s Owner or an Admin.
- In the dashboard, open your organization and go to Organization Settings → Members.
- Click Invite Member.
- Enter their email address, choose a role — Member, or Admin if you are the Owner (only the Owner can invite Admins) — and click Send Invite.
You cannot invite someone as Owner; every organization has exactly one, and ownership changes hands by transfer instead (see Transferring ownership).
A new Member has no project access until you add them to a group, so after they accept, put them in the right groups — see Groups & Project Permissions.
Invite a guest to a project
A project invitation is for people outside your organization — a contractor, or a collaborator from another company. Anyone who is the organization’s Owner or an Admin, or who holds the Manage project capability on the project, can send one from the project’s settings:
- Open the project’s Settings → Members.
- Click Invite Guest.
- Enter their email, tick the capabilities they should hold (View is always included), and click Send Invite.
Instead of a role, a project invitation carries the set of capabilities the guest will hold on that project — the same capabilities you would grant a group. When they accept, they become a project guest: they can work in that one project, they never appear in your organization’s member list, and they see nothing of the organization beyond its name. Per-environment overrides do not apply to guests; their grant covers the whole project.
You cannot send a project invitation to someone who is already a member of the organization — give them access through a group instead. This is checked when you create the invitation, and checked again when it is accepted, so an invitation sent to someone who later joins your organization cannot be accepted either.
What the invitee experiences
A platform administrator can also invite someone directly to d2o PaaS. They get their own account and personal organization, without joining an existing organization. Accepting takes them to their personal organization, with the plan chosen for them if one was specified.
New to the platform: their account is created the moment you send the invitation, and they receive an email asking them to set a password. Once they have set it, they land on the invitation’s accept page. Their first sign-in also creates a personal organization of their own.
Already on the platform: they receive an invitation email with a link to the accept page. Existing users are never sent a password reset. The one exception is an account that was created by an earlier invitation but never had its password set: it gets a fresh set-password email instead.
The accept page shows who invited them, what they are being invited to, and the email address the invitation was sent to. From there:
- If they are not signed in, a Sign in to accept button takes them through sign-in and back to the invitation.
- If they are signed in as a different account, the page tells them the invitation is for another email address and offers Sign in as a different account. An invitation can only be accepted by the account it was addressed to.
- Accept invitation completes the process and takes them to the organization — or, for a project invitation, straight to the project.
- Not now defers; the link keeps working for 7 days, until the invitation is revoked, or until it is resent (a resend issues a fresh link valid for another 7 days and retires the old one).
If the link has expired, the page says the date it expired and asks them to get a new one from the person who invited them. If the link is invalid or already used, the page says “Invitation not found.”
Resend and revoke
Pending invitations are listed below the member list on the Members tab (and with the project’s members for project invitations). Each shows when it expires — “Expires in 5 days”, or “Expired 2 days ago” in red once its 7 days are up. An expired invitation stays in the list until you resend or revoke it.
- Resend sends a fresh link, valid for another 7 days, and retires the old one — use it when an invitation expired or the email went missing. If the person never set a password from their first email, the resend sends a new set-password email instead. Someone who already has a password is never sent a password reset.
- Revoke cancels a pending invitation. Its link stops working, and the person can no longer join. You can always invite them again later.
An invitation that has already been accepted no longer appears in the pending list; remove the person from the organization or project instead.
Limits
To keep invitations from being used for spam, a few limits apply. Only invitations that are actually sent count; a resend that is refused does not.
- An organization can have at most 25 pending invitations at a time. Revoke stale ones to make room.
- Each person can send at most 25 invitations in any 24 hours, resends included.
- An account in its first 30 days can send at most 5 a day, unless d2o PaaS invited it directly or the invitations are for an organization on a paid plan.
- An organization can invite one email address at most 5 times in any 24 hours.
Next steps
- Groups & Project Permissions — give new members access to projects
- Managing Your Organization — roles, members, and ownership transfer
- Access Control — how roles, capabilities, and guests fit together
Account, SSH Keys & API Tokens
Your account settings cover three personal things: who you are on the platform, the SSH keys you push code with, and the API tokens the CLI signs in with. None of these are shared with your Organization — they belong to you.
Your profile
The platform is invitation-only: your account exists because you accepted an invitation and signed in. Your name and email come from that sign-in identity.
To check who you are signed in as from the CLI:
$ paas whoami
Ada Lovelace <ada@example.com>
The dashboard’s Account Settings page is where you manage your SSH keys.
SSH keys
An SSH public key lets you push app source over SSH — the git-push deployment flow. Keys are attached to your account, not to a single app: once added, a key can push to any app repository your account is allowed to push to. See Deploying with Git Push.
In the dashboard
- Open Account Settings.
- In the SSH Keys section, click Add Key.
- Give the key a name (for example,
laptop) and paste the public key — the contents of a file like~/.ssh/id_ed25519.pub, starting withssh-ed25519 AAAA.... - Click Add Key.
The list shows each key’s name, fingerprint, and the date it was added. To remove a key, click Delete and confirm — that key can no longer push to your app repositories. If adding fails, check that you pasted the public key (the .pub file) in full, on one line.
With the CLI
Add a key — by default the CLI uses the first of ~/.ssh/id_ed25519.pub, id_ecdsa.pub or id_rsa.pub it finds and names it after your machine and the file; or pass a file path:
paas keys add
paas keys add ~/.ssh/work_key.pub
List your keys:
$ paas keys list
ID NAME FINGERPRINT CREATED
809cd29b-da1e-4655-bd66-70a0d3f13e59 laptop SHA256:Ktu6yQ3f9EcXo1VbT8mJH2yLwPq7rDgN4sAv5ZiUxYo 2026-08-12T09:15:02.114210+00:00
Remove one by its ID:
paas keys remove 809cd29b-da1e-4655-bd66-70a0d3f13e59
API tokens
When you run paas login, the CLI opens your browser, you approve the sign-in, and the CLI receives an API token for that machine. By default the token is named cli plus your machine’s hostname; pass --name to paas login to pick your own.
Tokens are managed from the CLI. List them to see every machine that holds one:
$ paas auth tokens list
ID NAME PREFIX ADMIN LAST USED EXPIRES
939b36d1-1ff2-4c0c-a40a-6495738b7c2d cli ada-laptop paas_0a1b2c3 no 2026-08-31T21:24:14.758920+00:00 -
Each row shows the token’s name, its prefix (the first characters, so you can match it to a machine without exposing the token), when it was last used, and when it expires, if it has an expiry (most rows show “-”).
Revoke a token you no longer trust — a lost laptop, an old machine:
paas auth tokens revoke 939b36d1-1ff2-4c0c-a40a-6495738b7c2d
On the machine you are using, paas logout revokes this host’s token and forgets it locally.
For scripting, paas auth token prints the current token so you can call the platform API directly — it’s also what you’d hand to an MCP client or the audit log curl examples. Treat that output like a password — never commit it or paste it into shared channels:
curl -H "Authorization: Bearer $(paas auth token)" https://api.d2o.io/api/v1/users/me
For self-hosted installations, substitute your API origin; see API Access & Automation.
For CI pipelines, use a deployment token rather than your personal API token: it can only push to one environment or app, and it belongs to the organization, not to you. Deployment tokens aren’t listed by paas auth tokens list.
Deleting your account
From Account Settings, click Delete Account. Before anything happens, the dashboard shows you the full list of what deleting your account will remove, so you can review it before confirming:
- Every Organization you own — your personal Organization, and any team Organization you own (an Organization always has exactly one owner) — deleted entirely, including all of their projects, environments, apps, databases, buckets and their contents, secrets, and git repositories. If a team Organization you own has other members, they lose access to it — transfer ownership first if you want it to keep running instead.
- Your SSH keys and API tokens.
Organizations and Projects you belong to but don’t own are unaffected — you’re just removed from them.
Click Delete My Account and type your account email to confirm. This cannot be undone.
Backups of the deleted databases are removed within about an hour. Depending on your installation, your sign-in identity — the account you use to sign in — may not be deleted by this step; ask whoever operates your platform installation if you want it removed too.
The API behind this is GET /api/v1/users/me/deletion-preview, which returns the same list the dashboard shows, and DELETE /api/v1/users/me, which requires a JSON body confirming your email:
curl -X DELETE -H "Authorization: Bearer $(paas auth token)" \
-H 'Content-Type: application/json' \
-d '{"confirm_email": "ada@example.com"}' \
https://api.d2o.io/api/v1/users/me
A missing or mismatched email returns 400 with code: "CONFIRMATION_REQUIRED" and deletes nothing. There is no CLI command for this yet — use the dashboard or the API directly.
Next steps
-
API Access & Automation — endpoints and credential types
-
Deploying with Git Push — use your SSH key to deploy
-
Installing the CLI — set up
paasand sign in -
CLI Overview & Authentication — how CLI login and tokens work
-
Managing Your Organization — transfer ownership, manage members
-
AI Assistant and MCP Server & AI Agents — what your API token can do beyond
curl
AI Assistant
Every dashboard page has a small assistant launcher in the corner (the robot icon). Click it to open the d2o Assistant panel and ask a question in plain language — it stays open as you navigate, and Enter sends your message.
What it can answer
The assistant answers two kinds of questions:
- How the platform works. Questions about concepts, guides, and CLI usage are answered from the official documentation, with links to the pages it drew from shown under the answer.
- Your own account data. Questions like “how much bandwidth has
shopused this month?” or “is myproductionenvironment near a limit?” are answered by looking up your organizations, bandwidth and limit usage, projects, resources, and recent deployments — live, at the moment you ask. While it’s doing this, a small note appears naming the lookup in progress.
The assistant only sees what you can see: it runs every lookup as you, through the same permission checks as the rest of the platform, so it can never show you data from an organization you don’t belong to. It is read-only — it cannot deploy, scale, restart, edit secrets, or change anything else. If you ask it to make a change, it explains where in the dashboard to do it yourself.
If it can’t answer confidently from the documentation, it says so rather than guessing at steps, flags, or limits that don’t exist.
Rate limit
The assistant is rate-limited per user — see API rate limits for the number. Sending messages faster than that gets a “sending messages too quickly” notice in the panel; wait a moment and try again.
If the assistant says it’s not enabled
The assistant is optional per platform installation. If whoever operates yours hasn’t turned it on, the launcher still appears, but every message gets the same reply: “The AI assistant is not enabled on this deployment.” (Calling the API directly, this is a 503 response with the code ASSISTANT_DISABLED.) There’s nothing to fix on your end — ask whoever operates your platform installation if you’d like it enabled.
Next steps
- MCP Server & AI Agents — give an external AI tool the same account lookups
- Plans, Limits & Usage — the figures the assistant can look up for you
- Troubleshooting & FAQ — common problems, in case the assistant can’t help
MCP Server & AI Agents
The platform also exposes its read-only account lookups — the same ones the AI Assistant uses — to external AI tools over MCP (the Model Context Protocol), so an agent running in your own editor or desktop app can answer questions about your organizations, bandwidth, limits, projects, resources, and deployments without you switching to the dashboard.
Point any MCP-capable client at:
https://api.d2o.io/mcp
For self-hosted d2o, use the MCP address supplied by your operator. The hosted dashboard address does not serve this endpoint. See hosted addresses.
Authenticating
The MCP endpoint accepts only a paas_ API token as a Bearer credential — the same kind of token the CLI uses, and the same one from paas auth tokens list. It does not accept a dashboard session cookie. Get one by signing in with the CLI and printing it:
paas login --host https://dashboard.d2o.io
paas auth token
See Account, SSH Keys & API Tokens for managing and revoking tokens.
Configure your MCP client with the URL and an Authorization: Bearer header carrying the token. The exact configuration file depends on your client, but the shape is generally:
{
"mcpServers": {
"d2o-paas": {
"url": "https://api.d2o.io/mcp",
"headers": {
"Authorization": "Bearer paas_xxxxxxxxxxxxxxxxxxxx"
}
}
}
}
Treat the token like a password — anyone who has it can act with your account permissions through the API. MCP tools are read-only; that restriction does not narrow the token’s access to other API routes.
If the request has no token, or an invalid one, the endpoint replies 401 Unauthorized with a WWW-Authenticate: Bearer header rather than any account data.
What it can do
The same tools the AI Assistant uses are available here: list your organizations, look up bandwidth and plan-limit usage, list projects and resources, review recent deployments, and search the documentation. Every call runs as the token’s owner, through the same permission checks as the rest of the API — an agent can never see more through MCP than you could see yourself. It is read-only: there is no tool to deploy, scale, or change anything.
Protocol details
The server speaks MCP’s streamable HTTP transport as a stateless JSON endpoint: it does not pin a session to a connection, and it answers with a plain JSON response rather than an event stream. This means it works cleanly behind an ordinary load balancer and with simple HTTP-based MCP clients.
If your MCP client can’t reach /mcp on a self-hosted installation, ask whoever operates it to check the server’s allowed-hosts configuration.
Next steps
- AI Assistant — the same lookups, from the dashboard
- Account, SSH Keys & API Tokens — creating and revoking API tokens
- CLI Overview & Authentication — signing in and getting a token
CLI Overview & Authentication
The paas command-line tool covers the same ground as the dashboard — Organizations, Projects, Environments, and Resources — from your terminal. This page explains how it authenticates, where it keeps its state, and how it decides which Organization, Project, Environment, and app a command applies to. For a command-by-command listing, see the Command Reference.
Install
On macOS and Linux, one line installs the paas binary — no Rust toolchain needed:
curl -fsSL https://dl.d2o.io/install.sh | sh
paas --version
The full walkthrough — manual downloads, checksum and signature verification, and Windows — is in Installing the CLI.
Log in, check who you are, log out
Login happens in your browser, so your password never touches the terminal:
paas login --host <your-platform-url>
<your-platform-url> is the full dashboard origin, including https:// (hosted d2o: https://dashboard.d2o.io). The CLI opens the platform’s sign-in page, then a consent page, Approve d2o CLI sign-in, showing the account you’re signed in as and the token name; click Approve sign-in. That sends a short-lived, single-use code back to the CLI, which exchanges it for an API token and stores it. The tab then reads “Signed in to d2o PaaS” and you can close it. If no browser opens, visit the URL the CLI prints on the same machine. For remote hosts, see Remote terminals and CI. Each login mints a named token (default cli <hostname>); pick your own name with --name "work-laptop". Logging in again to the same host asks the server to revoke the previously stored token before replacing it (best-effort — if the revoke fails, the old token remains listed under paas auth tokens list and can be revoked there).
paas whoami # show the logged-in user
paas logout # revoke this host's token and forget it locally
You can list and revoke your tokens at any time with paas auth tokens list and paas auth tokens revoke <id>, and print the current token for scripting with paas auth token. See Account, SSH Keys & API Tokens.
Where the token is stored
The CLI keeps its state in a single file, config.toml, in your platform’s standard config directory: ~/.config/paas/config.toml on Linux, ~/Library/Application Support/paas/config.toml on macOS, %APPDATA%\paas\config.toml on Windows. (Set PAAS_CONFIG_DIR to override the directory.)
The file holds one entry per host — the API token, and your default Organization and Project for that host — plus the current_host your last login pointed at. You can be logged in to several installations at once and switch between them with --host or by logging in again.
The token is stored in plain text, so on Linux and macOS the CLI protects the file at the filesystem level: the directory is created with mode 0700 and the file is written atomically with mode 0600 — it is never world-readable, even for a moment. Windows has no such permission bits, so the file inherits whatever your user profile directory allows. Either way, treat it like a password: do not commit it or copy it between machines.
Environment variables
Three variables override the config file, which is what you want in scripts and CI:
PAAS_API_URL— the API host to talk to.PAAS_API_TOKEN— the API token to use. An environment-supplied token is never written to disk, andpaas logoutrefuses to clear it — unset the variable instead.PAAS_CONFIG_DIR— an alternate config directory; the CLI then reads and writes$PAAS_CONFIG_DIR/config.toml.
Precedence, per value:
- API host:
--hostflag >PAAS_API_URL> the configuredcurrent_host. - Token:
PAAS_API_TOKEN> the stored token for the resolved host. If neither exists, commands fail with “not logged in” and point you atpaas login.
How the CLI finds your context
Most commands need some combination of Organization, Project, Environment, and app. Each value is resolved independently; the first source that supplies it wins:
-
Flags:
-o/--org,-p/--project,-e/--env,-a/--app— available on every command. -
The git remote of the current repository —
d2o, elsepaas. An Environment’s shared remote (…/sources/acme/shop/production.git) encodes Organization, Project and Environment, and the app is the one whose source directory contains your working directory (or the only app building from the repository root); an app’s own remote (…/sources/acme/shop/production/web.git) encodes all four values. When your working directory matches none of the shared remote’s apps — a checkout that still has an older per-apppaasremote left over from befored2oexisted — the CLI falls back to thatpaasremote’s app rather than erroring. The remote is ignored, with a printed hint, when it belongs to a different installation than the API host you are talking to. It is also ignored when-onames a different Organization than the remote’s. When-pnames a different Project, only the remote’s Organization carries over;-ealone still takes the rest from the remote. -
Stored defaults for Organization and Project, saved per host:
paas orgs set-default acme paas projects set-default shopEnvironments and apps have no stored default — supply those via a flag or the git remote.
-
Otherwise the command errors, naming the missing value and each way to supply it.
JSON output
Every read command accepts --json, which prints the raw API response instead of a formatted table:
paas projects list -o acme --json
This is the stable contract for scripting. The streaming commands are the exception: paas logs --tail, paas watch and paas exec reject --json, since a live stream and an interactive session have no JSON to render.
Shell completions
Generate completions for your shell and install them in its completion directory:
paas completions zsh
Supported shells: bash, elvish, fish, powershell, zsh.
Next steps
-
API Access & Automation — endpoints and automation credentials
-
Command Reference — every command and flag
-
Installing the CLI — install and first login, step by step
-
Deploying with Git Push — set up the
paasord2ogit remote -
Account, SSH Keys & API Tokens — manage tokens and keys
Command Reference
This page documents every paas command. Install the CLI and log in first — see
Installing the CLI and
CLI Overview & Authentication. Most of what the CLI can do is
also available in the dashboard; the guide pages cover both paths side by
side. A few operations — managing API tokens and printing a bucket’s S3
credentials — are currently CLI-only.
Run paas --help for the full command tree, or paas <command> --help for any
command’s own flags and arguments.
Global flags
Every command accepts the same set of global flags:
--host <HOST>— the platform address to talk to (the address you open the dashboard at, includinghttps://, written as<your-platform-url>in these docs). Overrides thePAAS_API_URLenvironment variable and the configured current host, so you can target a different installation for a single command.--json— print the raw API JSON response instead of a table. Useful for scripting, for example piping intojq.-o, --org <ORG>— the Organization to act on (its slug, e.g.acme).-p, --project <PROJECT>— the Project within that Organization (e.g.shop).-e, --env <ENV>— the Environment within that Project (e.g.production).-a, --app <APP>— the app (a Web Application or Background Worker Resource) within that Environment (e.g.web); bucket commands use the same flag to select a bucket.-h, --help— print help.paas -Vprints the CLI version.
Most commands need some or all of the -o/-p/-e/-a context to know what
to act on. You can save a default Organization and Project with
paas orgs set-default and paas projects set-default so you do not have to
repeat the flags in every command. The examples below spell the flags out in
full the first time each appears, then rely on context where it keeps the
example readable.
paas login
paas login [--name <NAME>] [--host <your-platform-url>]
Logs you in via your browser: the CLI opens the platform’s sign-in page, you approve the request, and the CLI stores an API token for that host. Accounts on the platform are created by invitation, so you need an invitation before your first login — see CLI Overview & Authentication.
--name <NAME>— the token name shown inpaas auth tokens list(default:cli {hostname}), handy for telling machines apart later.
paas login --host <your-platform-url> --name "work laptop"
paas logout
paas logout
Revokes this host’s token and forgets it locally. Run it on any machine you no longer want to have access.
paas whoami
paas whoami
Shows the logged-in user. A quick way to confirm which account (and, with
--host, which installation) you are talking to.
paas whoami --json
paas auth
Manage authentication and API tokens.
-
paas auth token— print the current API token, for scripting. For example, to hand it to another tool:TOKEN=$(paas auth token) -
paas auth tokens— manage this user’s API tokens.paas auth tokens listshows every token on your account with the name each was given at login. Revoke a token you no longer need withpaas auth tokens revoke <ID>, using the ID shown bypaas auth tokens list;paas logoutrevokes the current machine’s token.
Treat the printed token like a password: anyone holding it can act as you.
paas orgs
paas orgs <list|info|create|update|transfer|delete|set-default>
Manage Organizations. paas orgs info shows one Organization, falling back to
the resolved -o context when you do not name one. paas orgs set-default
saves the Organization the CLI should assume when -o is omitted. Deleting an
Organization is permanent — the dashboard’s Organization settings page walks
you through the same operation with a confirmation step.
create <SLUG>andupdate <SLUG>take--name <NAME>(the display name) and--description <DESCRIPTION>.transfer <SLUG> --to <EMAIL_OR_USER_ID>transfers ownership to an existing member. The organization slug is required explicitly; the recipient can be identified by a case-insensitive email match or user UUID. Only the owner can transfer a team organization; personal organizations cannot be transferred. The previous owner becomes an Admin immediately. Type the organization slug at the prompt, or pass--confirm <SLUG>for non-interactive use.--jsonprints the updated organization returned by the API.delete <SLUG>asks you to type the slug to confirm. The prompt names what will be deleted and the platform it is on, so you can tell staging from production. Pass--confirm <SLUG>to skip the prompt in scripts; without it, a delete run outside a terminal is refused rather than guessed.
paas orgs list
paas orgs info -o acme
paas orgs create acme-labs --name "Acme Labs" --description "Experiments"
paas orgs transfer acme-labs --to new-owner@example.com --confirm acme-labs
paas orgs delete acme-labs --confirm acme-labs
See Managing Your Organization for what lives at the Organization level.
For prerequisites and the equivalent dashboard flow, see Transferring ownership.
paas projects
paas projects <list|info|create|update|delete|set-default>
Manage Projects within an Organization. paas projects info falls back to the
resolved -p context when you omit a name, and paas projects set-default
saves your usual Project and makes its Organization the default too. A default
Project applies only within its own Organization: with -o naming another,
it isn’t used, and paas orgs set-default to a different Organization clears
it.
create, update and delete take the same flags as paas orgs:
--name, --description, and --confirm <SLUG> on delete.
paas projects list -o acme
paas projects create shop -o acme --name "Shop" --description "Storefront and API"
paas envs
paas envs <list|info|create|update|delete>
Manage Environments within a Project. paas envs info falls back to the
resolved -e context when you omit a name; list and info show each
Environment’s release source.
create <NAME>requires--type development|staging|production. It also takes--branch <BRANCH>,--no-auto-deploy(create with automatic deployments turned off) and--release-source build|promote.update <NAME>takes--branch <BRANCH>,--auto-deploy true|falseand--release-source build|promote.delete <NAME>asks you to confirm; pass--confirm <NAME>in scripts.
info also shows whether the Environment is protected. Protection is turned
on in the dashboard (Environment settings → Protected); the CLI has no
flag for it.
build Environments build their apps from git; promote Environments only
run images promoted from another Environment — see
Pipelines, Promotion & Rollback.
paas envs list -o acme -p shop
paas envs create staging -o acme -p shop --type staging --release-source promote
paas envs update production -o acme -p shop --auto-deploy false
paas apps
paas apps <list|info|create|update|delete|attachments|attach|detach>
Manage apps — your Web Application and Background Worker Resources — within an
Environment. paas apps info falls back to the resolved -a context when you
omit a name.
list and info show each app’s status, with
(blocked - over monthly transfer allocation) appended when the app has been
scaled to zero for being over its monthly transfer
allocation — see
Troubleshooting & FAQ.
create <NAME> and update <APP> take the same settings as the dashboard
form:
--framework <FRAMEWORK>— required on create: one ofnodejs,rails,python,go,rust,static(see Supported Frameworks). An unknown value is rejected with an error naming the field and the accepted values.--port <PORT>— the web app’s HTTP port, or the port used by a worker’s HTTP health check. Defaults to3000when omitted.--health-check-path <PATH>— for a Web Application, the path a new version must answer on before it takes traffic. New Static Files web apps default to/when this is omitted. Background Workers can set one too, as an alternative to--health-check-command. Onupdate, pass""to clear it.--health-check-command <COMMAND>— Background Workers only: a command that must exit0for a new instance to count as healthy. Without a path or a command, a worker counts as healthy once it runs for 30 seconds without restarting. Onupdate, pass""to clear it.--replicas <N>,--cpu <CPU>(e.g.500m,1),--memory <MEMORY>(e.g.512Mi,1Gi).--autoscaling-min <N>,--autoscaling-max <N>,--autoscaling-cpu-target <PERCENT>— any of them turns autoscaling on.--autoscaling true|false(update only) turns it on or off explicitly;falsecan’t be combined with the other three. See Scaling & Autoscaling.--worker(create only) — create a Background Worker instead of a Web Application.--git-repo,--git-branchand--source-mode push|external(create only) appear in the help but building from an arbitrary git URL isn’t supported; apps deploy from their platform-hosted repository.--shared-source— build from the Environment’s shared repository instead of one of the app’s own;update --own-sourceswitches back. See One remote for several apps.--source-dir <DIR>— with a shared source: the directory inside the repository the app’s build starts in (default: the repository root;.on update resets it).--process <NAME>— the Procfile process to run when the build produces several (for exampleweborworker). Onupdate, pass an empty string (--process "") to clear it.--start-commandstill works as an older name for the same flag.--release-command <COMMAND>— runs once against each deployment’s new version, before any instance starts; see Release command. Onupdate, pass""to clear it and fall back to a Procfilerelease:line, if any.--auto-rollback true|false— whether a version that never becomes healthy is automatically replaced with the last version that was Live; on by default. See When a new version isn’t healthy.
Any of --process, --port, --health-check-path, --health-check-command,
--auto-rollback, or --singleton can instead be set by the app’s own
d2o-paas.yaml; --release-command can be set
there too, or by the Procfile’s release: line — the Procfile has no effect
on the others (process name matching doesn’t count as setting --process;
see Procfile). While a deployed version sets one
of these that way, update on it fails instead of silently being overridden
on the next deployment:
`health_check` is set in d2o-paas.yaml at abc1234; change it there
delete <APP> asks you to confirm; pass --confirm <APP> in scripts.
create and update also take --singleton true|false. A singleton runs at most
one instance; deployments and restarts stop the old instance before starting the
new one, so expect a few seconds of downtime. Use it for bots and other apps
that must never run twice. It can’t be combined with autoscaling or more
than one replica — see Singleton apps.
paas apps info -o acme -p shop -e production -a web
paas apps create web -o acme -p shop -e production --framework nodejs --port 3000
paas apps update web -o acme -p shop -e production --memory 1Gi
See Creating a Web Application for the full create flow, and Deploying with Git Push or Deploy from GitHub or GitLab for getting code to it.
App attachments
paas apps attachments APP
paas apps attach APP RESOURCE [--env-var NAME | --prefix PREFIX]
paas apps detach APP ATTACHMENT_ID
Use -o, -p, and -e to select the Environment. APP resolves to a Web Application
or Background Worker; RESOURCE resolves to a Database or Object Storage resource in
that Environment. For databases, --env-var defaults to DATABASE_URL. For buckets,
omit --prefix for the primary S3 variables or provide a prefix for another bucket.
Wrong-type flags are rejected, and the API enforces the relevant project permissions.
The attachment list includes the ID required by detach and never prints credentials.
Detaching affects the app’s connection only; it does not delete the backing resource.
All three commands support --json.
paas manifest check
paas manifest check [FILE] -o acme -p shop -e production
Validates a d2o-paas.yaml file against an
Environment’s apps, without pushing. FILE defaults to d2o-paas.yaml in the
current directory.
$ paas manifest check -o acme -p shop -e production
d2o-paas.yaml is valid for production
Errors print the same way a failed deployment’s would, with the line and field at
fault; warnings print as warning: … lines and don’t affect the exit code.
Exits 1 if the file is invalid and 2 if it can’t be read, so it’s usable
as a CI gate before pushing:
paas manifest check -o acme -p shop -e production && git push paas main
See d2o-paas.yaml Reference for every field, error, and warning.
paas dbs
paas dbs <list|info|create|update|delete>
Manage Database Resources within an Environment. Available engines are PostgreSQL, Redis, MongoDB, and MariaDB. Backups run automatically for PostgreSQL and MariaDB; restoring from a backup requires contacting support. See Databases and Database Backups.
create <NAME>requires--type postgresql|redis|mongodb|mariadb, and takes--version <VERSION>and--storage-gb <GB>.createalso takes--ha, turning on high availability for a PostgreSQL database. Any other--typewith--hais rejected.update <DB>takes--ha true|false, toggling high availability on an existing PostgreSQL database. Enabling it copies the data to the new standby in the background with no downtime; disabling it removes the standby. The connection string and any attachments are unaffected.delete <DB>asks you to type the name to confirm; pass--confirm <DB>in scripts.
paas dbs list -o acme -p shop -e production
paas dbs create shop-db -o acme -p shop -e production --type postgresql --storage-gb 10
paas dbs create shop-db -o acme -p shop -e production --type postgresql --ha
paas dbs update shop-db -o acme -p shop -e production --ha true
paas config
paas config <list|set|unset> [--scope org|env|app]
Manage environment variables for the app selected with -a, or — with
--scope — the plain variables every app inherits from its Organization or
Environment.
--scope app(the default) acts on the app’s own variables and needs the full-o/-p/-e/-acontext.--scope envacts on the Environment’s variables (-o/-p/-e), which every app in that Environment inherits.--scope orgacts on the Organization’s variables (-o), which every app in the Organization inherits.
An app’s own variable overrides an Environment variable with the same key, which in turn overrides an Organization variable.
paas config settakes one or moreKEY=VALUEpairs, applied in order; it stops at the first failure. Alternatively,--secret-ref NAME KEYpoints a single bare key at a platform-managed secret instead of a literal value, so the secret’s value reaches your app without ever appearing on your command line or in your shell history.--hiddenstores each value as a hidden value, like the dashboard’s Secret value (hidden): nobody can read it back, and it needs the Manage secrets capability.KEY=-reads that value from stdin, so it stays out of your shell history; on a terminal it’s typed without echo. An empty value is refused. A plain value can’t be saved over a hidden one.- For
--scope envand--scope org, a value that was saved but that some apps couldn’t take yet prints a warning naming those apps, and the CLI carries on with the next key. Run the same command again to retry them. paas config unset <KEY>removes one variable.paas config listshows the variables in scope. For an app it includes the ones inherited from its Environment and Organization, with a SOURCE column and(overridden)on any an app variable replaces (secret links show as<secret:NAME>);--jsonprints only the app’s own.
paas config set -a web LOG_LEVEL=info FEATURE_FLAGS=beta
paas config set -a web --secret-ref DB_PASSWORD DATABASE_PASSWORD
printf %s "$API_TOKEN" | paas config set -a web --hidden API_TOKEN=-
paas config set --scope env -e staging LOG_LEVEL=debug
paas config unset --scope org LOG_LEVEL
--secret-ref and --hidden work with the app scope only; Organization and
Environment variables are plain values.
See Environment Variables and Managing Secrets.
paas domains
paas domains <list|add|verify|remove|release>
Manage the custom domains of the Web Application selected with -a.
list— show the app’s custom domains, the status of each (unverified,verifying,active,error) and when it was added. The reason behind anerroris printed under the table.add <DOMAIN>— claim a hostname, for exampleapp.example.com, and print both DNS records to create for it: the ownershipTXTand the trafficCNAME(orALIAS, which some DNS providers callANAME, for a root domain). The domain is not served and gets no certificate until it is verified.verify <DOMAIN>— check the ownership record and, when it matches, verify the domain. On a mismatch the record that has to exist is printed again.remove <DOMAIN>— detach a hostname from the app.release <DOMAIN>— free a domain another organization is serving, by proving you own it. Organization-scoped: it takes-oonly, not-p/-e/-a.
paas domains add app.example.com -o acme -p shop -e production -a web
paas domains verify app.example.com -a web
paas domains list -a web
paas domains remove app.example.com -a web
paas domains release example.com -o acme
Adding, verifying or removing a domain needs the Manage web apps capability;
release needs organization Owner or Admin. Verifying a domain, and removing a
verified one, redeploy the app; adding one does not — an unverified claim
changes nothing. Only one organization can hold a domain verified, so adding a name
another organization is already serving fails with “That domain is already in
use on the platform.” — take it back with paas domains release, then add it
again. A claim left unverified for 7 days is dropped, and an apex domain does
not cover its subdomains: each hostname is verified on its own.
See Custom Domains & TLS. Background Workers, Databases, and buckets have no custom domains.
paas buckets
paas buckets <list|info|create|delete|migrate|ls|url|visibility|creds|put|get|rm>
Manage Object Storage resources within an Environment. list, create and
delete act on -o/-p/-e alone, like paas dbs. migrate takes a positional bucket name; the remaining subcommands select a bucket with -a.
list— list the Environment’s buckets, with aTIERcolumn (capacityorha, or<tier> -> <target> (<phase>)while a tier move is in progress) and aSTATUScolumn that appends(blocked - over monthly transfer allocation)when the bucket’s public visibility is currently suspended — see Plans, Limits & Usage.create <NAME>— create a bucket. Takes--publicto make it public immediately,--tier capacity|ha(large, low-cost, single-node storage by default;hareplicates it so it survives node loss), and--quota-gbfor a cap on the bucket’s total stored data (10 GB when you leave it out). The tier is only the starting point — move it later withmigrate.delete <NAME>— asks you to type the name to confirm; pass--confirm <NAME>in scripts. Refused while a tier move is in progress.migrate <NAME> --tier capacity|ha— move the bucket to a different storage tier; see Moving a bucket to a different tier. Takes--yesto skip the confirmation prompt (required in a non-interactive session) and--waitto poll until the move finishes instead of returning immediately.info— show the bucket’s details, including its tier (same<tier> -> <target> (<phase>)format while moving) and, if the bucket is blocked, astatus: blocked - over monthly transfer allocationline and apublic:line readingprivate (blocked; public once unblocked)if its stored visibility is public.ls [PREFIX]— list objects, optionally under a prefix. Only the first page is shown; a note says when more objects exist — narrow it with a prefix.url— print the bucket’s public URL (fails if none is assigned yet).visibility public|private— make the bucket public or private.creds— print S3-compatible credentials as shellexportstatements (--format env, the only format and the default), so you can use any standard S3 tool against the bucket.put <FILE> [KEY]— upload a file.KEYdefaults to the file’s own name.--content-typeoverrides the guess the CLI makes fromFILE’s extension.get <KEY> [FILE]— download an object.FILEdefaults toKEY’s base name; pass-to write the object’s bytes to stdout instead.rm <KEY>— delete one object.
put and get transfer the file directly to and from storage over a
short-lived signed link. The link itself is never printed except in --json
output, where it stays valid for 15 minutes — treat that output as a secret.
A get that fails partway never touches an existing file at the destination.
paas buckets create media -o acme -p shop -e staging --tier ha --quota-gb 50
paas buckets migrate media --tier ha -o acme -p shop -e staging --wait
eval "$(paas buckets creds -a media)"
paas buckets ls -a media
paas buckets ls images/ -a media
paas buckets put ./banner.png -a media
paas buckets get banner.png ./local-banner.png -a media
paas buckets rm old-banner.png -a media
paas buckets visibility public -a media
paas buckets delete media --confirm media
paas attach / paas detach / paas attachments
paas attach <DB_OR_BUCKET> [--as <NAME>] -a <APP>
paas detach <DB_OR_BUCKET> [--as <NAME>] -a <APP>
paas attachments -a <APP>
Attach or detach a Database or Object Storage bucket from the app selected
with -a. The Database/bucket and the app must be in the same Environment.
attach <DB_OR_BUCKET>— for a Database,--as <NAME>sets the injected connection-string variable’s name (defaultDATABASE_URL); for a bucket,--as <PREFIX>sets the credential-variable prefix (default: none, the app’s primary bucket, using the plainAWS_*/S3_*names). Prints what the app now receives.detach <DB_OR_BUCKET>— removes the attachment. The Database or bucket itself is untouched. If the same source is attached to the app more than once (under different names/prefixes),--as <NAME>says which one to remove; without it, the CLI lists the names in use and refuses to guess. A--asthat names no attachment is refused, even when there is only one.attachments— list what’s attached to the app: source, type, variable name or prefix, and when it was attached.
paas attach shop-db -a web
paas attach shop-db --as REPORTING_DB_URL -a worker
paas attach media --as ASSETS -a web
paas attachments -a web
paas detach media -a web
See Attaching a Database and Object Storage Buckets.
paas git
paas git remote [-a <APP>] [--https] [--print]
Without -a, prints — and by default configures — the Environment’s shared
remote as d2o. With -a, the app’s own remote as paas (a shared-source
app’s remote is the Environment’s, so it is set as d2o). Outside a git
repository it only prints the URL. Run it inside your repository, then deploy
with an ordinary push:
paas git remote -e production
git push d2o main
--print— only print the URL; don’t touch the local git remote.--https— use the HTTPS remote instead of SSH. The push token (valid for 30 days) is stored with git’s credential helper, never in the remote URL; re-run the command to get a fresh one. It needs a credential helper, such as your OS keychain orgit config --global credential.helper store, and stops with an error when none is configured. Before CLI 0.11.0 the token was written into the remote URL.
A promote-only Environment has no remote: pushes to it are refused with the
upstream Environment to push to. Pushing over SSH uses the keys you register
with paas keys. See Deploying with Git Push.
paas keys
paas keys <list|add|remove>
Manage your SSH keys, used to authenticate git push deployments.
paas keys add [FILE] uploads an SSH public key and defaults to the first of
~/.ssh/id_ed25519.pub, id_ecdsa.pub or id_rsa.pub it finds, so on most
machines this is all it takes:
paas keys add
paas keys add ~/.ssh/work_ed25519.pub
paas keys remove <ID> deletes a key from your account, using the ID from
paas keys list:
paas keys list
paas keys remove <ID>
You can also manage keys in the dashboard under your account settings — see Account, SSH Keys & API Tokens.
paas deploy-tokens
paas deploy-tokens <create|list|revoke>
Push credentials for CI, scoped to the environment’s remote, or to an app’s own remote with -a.
create <name> [--expires 30|90|365] (default: never expires) prints the token once, with the remote
URL and a ready-made git push line; CI needs full git history (fetch-depth: 0 on GitHub Actions,
GIT_DEPTH: 0 on GitLab CI). list shows name, creator, expiry and last use, never the token.
revoke <name|id> stops a token immediately; pass -a for a token scoped to an app. See
Deploying from CI.
paas connections
paas connections <list|connect>
list prints a table of the Organization’s GitHub and GitLab connections —
ID, PROVIDER, HOST, ACCOUNT and STATUS. Use the ID with paas links set --connection. Connecting is browser-only: connect prints:
Connect from the dashboard: <dashboard URL>/orgs/<org>/settings/integrations
See Deploy from GitHub or GitLab.
paas links
paas links <get|set|unset|deploy-latest>
Links an Environment’s or an app’s (-a) deployment target to a repo and branch
on a connected provider.
getprints the current link, for example “Deploys from GitHub acme/shop@production”, plus its settings (whether direct pushes are allowed, whether it waits for CI), the last push and its state (Last push: abc1234 Waiting for checks), and a warning if the provider connection is broken — or that nothing is linked.set --connection <id> --repo <owner/name|id> --branch <branch> [--allow-direct-push] [--wait-for-ci [<true|false>]]creates or updates the link, using the connection ID frompaas connections list.--reporesolvesowner/nameagainst the connection’s repos.setreplaces the whole link, so leaving out--allow-direct-pushor--wait-for-cion an existing link turns each one off again, even if it was previously on. A bare--wait-for-ci(no value) meanstrue, same as--allow-direct-push. See Wait for CI for what it waits for.unsetremoves the link; direct pushes are allowed again immediately.deploy-latestdeploys the linked branch’s current head on demand. It printsDeploying <sha>,Already deployed <sha>when that commit is already live (nothing is redeployed), or — with Wait for CI on —Waiting for checks on <sha>.
For a GitLab repository, verify your GitLab account in the dashboard first
(Settings → Deploy from GitHub/GitLab, then Edit settings if a
repository is already linked → Choose with your GitLab account). That verification lasts about two hours and also lets
paas links link GitLab repositories you maintain.
paas links set --connection <id> --repo acme/shop --branch production -e production
paas links set --connection <id> --repo acme/shop --branch production -e production --wait-for-ci true
paas links get -e production
paas links deploy-latest -e production
See Deploy from GitHub or GitLab.
paas deploy
paas deploy -a web
Triggers a deployment of the app selected with -a: the platform rolls the app
forward to its current desired configuration. Useful when you want to roll out
a change now rather than wait, or in an Environment where automatic deployments
are turned off. It first deploys the app’s latest successful build, including
one waiting as Built because automatic deployments are off. In a promote-only
Environment it is refused; use paas promote instead. See
Deployments, Health Checks & Rollback.
paas status
paas status -o acme -p shop -e production -a web
Shows the deployment status of the app selected with -a: whether what is
running matches what you have configured, the app’s health, and its running
instances.
paas deploys
paas deploys [list] [--before <N>] [--limit <N>] [-a <APP>] -e production
paas deploys info <N> -e production
paas deploys rollback [N] [--yes] -e production
list (the default) shows 25 deployments per page, newest first (--limit sets
1–100). When there may be more, it prints a More: paas deploys list --before <N> line to paste for the next page. With -a, only deployments that included
that app are listed. Columns: number, stage, apps, commit, who pushed, age,
and failure reason. Accepts --json. info <N> prints the full timeline for one deployment — every
stage and app event it went through.
rollback [N] rolls back a deployment that failed; without a number, the
Environment’s most recent deployment, if it ended Failed or Rollback failed
(when a newer deployment is live, it exits and asks for a number). A manual
rollback of deployment N goes back to the version that was live before N.
Asks for confirmation unless you pass --yes. Only the most recent
deployment can be rolled back this way — for an earlier one, push a fix (or
revert the commit and push that) in a build Environment, or promote the
version you want in a promote-only one. (This is different from paas rollback --to <id>, below,
which re-pins a promote-only Environment to any earlier version regardless
of health.)
paas releases is kept as an alias for paas deploys list. See
Deployments, Health Checks & Rollback.
paas promote
paas promote <app> --from <env> --to <env> [--yes]
paas promote --all --from <env> --to <env> [--yes]
Copies an app’s built image from one Environment into the next — the image
is never rebuilt. The target must be promote-only. With --all, every app
present in both Environments is promoted. A protected target prompts for
the Environment’s name unless you pass --yes. Prints one row per app with
its outcome and code; exits 1 when any row errored, or when the single app
you named was skipped for any reason other than ALREADY_CURRENT. See
Pipelines, Promotion & Rollback.
paas rollback
paas rollback <app> --to <deployment-id>
Re-pins an app on a promote-only Environment to an earlier image. Take the
id from paas deploys list --json. Refused on a build stage (BUILD_ENV)
and when the app already runs that image (ALREADY_CURRENT).
paas ps
paas ps -a web
Lists the running instances of the app selected with -a. The instance names
it prints are what the --instance flag on logs, run, and exec accepts when
you need to target one specific instance. When no instance is running yet, it
says why — for example, that the build is still running or failed.
paas watch
paas watch [--deploy <N>] [--quiet] [--timeout <SECONDS>]
Follows a deployment stage by stage — Received, Queued, Building, Built, Running release command, Releasing, Starting, Live — streaming its build and release logs as it goes, and prints the deployment page’s URL. Run it right after a push:
git push paas main
paas watch --deploy 42
Without --deploy, it follows the newest deployment for the app or Environment
inferred from your paas git remote; inside an Environment’s shared
repository with no -a, it follows every app the push affects.
--quiet— print stage changes only, without streaming the logs.--timeout <SECONDS>— how long to wait before giving up, default1800.
Exits 0 when the deployment reaches Live, 1 when it ends Failed, Rolled back,
Rollback failed, or Replaced, and 2 on a timeout, a lost connection, or a
usage error. It watches the deployment’s
event stream rather than polling, so like paas logs --tail, it rejects
--json. See
Deployments, Health Checks & Rollback.
paas scale
paas scale <N> -a web
Sets the app’s replica count to N running instances.
paas scale 3 -a web
If autoscaling is enabled for the app, manual scaling is rejected — change the
autoscaling bounds, or turn autoscaling off first with
paas apps update web --autoscaling false. Scaling is also refused while the app is blocked for being over
its monthly transfer allocation. A singleton app can be scaled to 0 or 1
only.
See Scaling & Autoscaling.
paas restart
paas restart -a web
Restarts the app selected with -a, replacing its running instances. See
Restarting an App.
paas logs
paas logs [--tail] [--instance <INSTANCE>] [--container <CONTAINER>] [-n <NUM>] -a web
paas logs --build <N> [--release] [--app <NAME>]
Shows logs for the app selected with -a: a snapshot by default, or a live
stream with --tail.
--tail— follow logs live. Both the snapshot and the live stream show every running instance; once more than one instance has printed, each line is prefixed with[instance-name]. The stream starts from each instance’s last 200 lines, so combining--tailwith--container,-n, or--jsonis a usage error.--instance <INSTANCE>— show only one instance (names come frompaas ps); works with snapshots and--tail.--container <CONTAINER>— rarely needed; leave it unset unless support asks you to use it.-n, --num <NUM>— how many lines the snapshot returns (default 500, maximum 10000).--build <N>— a one-shot snapshot of the build log for deploy#Ninstead of app instance logs. Build logs are saved for the Environment’s 1000 most recent deploys (the last 1 MB of each); usepaas watchto follow a build live.--release— the deployment’s release command log instead of its build log.--app <NAME>— which app’s log; required when the deployment covers more than one app (a shared build or a push to an Environment remote).
paas logs -a web -n 100
paas logs -a web --tail
paas logs --build 42
paas logs --build 42 --release --app worker
See Logs and Deployments, Health Checks & Rollback.
paas run
paas run [--instance <INSTANCE>] [--container <CONTAINER>] -a web -- <COMMAND> [ARGS...]
Runs a one-shot command in a running instance of the app and prints its
output. The -- before the command is required so the command’s own flags are
not parsed as paas run’s:
paas run -a web -- ls -la
Typical uses are database migrations, seed scripts, and quick inspections. See Console & One-off Commands.
paas exec
paas exec [--instance <INSTANCE>] [--container <CONTAINER>] [--command <COMMAND>] -a web
Opens an interactive terminal in a running instance of the app — the CLI
equivalent of the dashboard’s web console. It requires stdin and stdout to be
a real terminal, and --json is not supported (there is nothing to render as
JSON in an interactive session). Use --instance to pick a specific instance and
--command to choose the program to start in the session.
paas exec -a web
paas completions
paas completions <SHELL>
Generates shell completions for bash, elvish, fish, powershell, or
zsh. Write the output where your shell loads completions from, for example:
paas completions zsh > ~/.zsh/completions/_paas
Next steps
API Access & Automation
Use the dashboard for interactive management, the CLI for terminal workflows, and the API when a script needs structured responses. API requests run with the account’s existing permissions; an API token does not grant additional access.
Hosted addresses
| Purpose | Hosted d2o address |
|---|---|
| Dashboard and browser-based CLI sign-in | https://dashboard.d2o.io |
API origin / PAAS_API_URL for scripts | https://api.d2o.io |
| REST API prefix | https://api.d2o.io/api/v1 |
| MCP endpoint | https://api.d2o.io/mcp |
| Documentation | https://docs.d2o.io |
For a self-hosted installation, obtain these addresses from your operator. They can share a hostname or use different hosts; do not assume the dashboard serves /mcp. Examples using a full origin require the https:// scheme exactly once.
Choose the right credential
| Credential | Used for | Scope and management |
|---|---|---|
Account API token (paas_…) | CLI, REST API, MCP | Acts as its owner with their permissions. Manage and revoke through Account. MCP tools are read-only, but the same token can authorize writes through the REST API. |
| Personal HTTPS push token | Git over HTTPS | Issued for the selected remote; expires after 30 days. Refresh with paas git remote --https. |
| SSH key | Git over SSH | Registered to your account; remove lost keys in Account settings or with the CLI. |
| Deployment token | Automated Git pushes | One Environment or app remote; organization-owned. Optional expiry. It cannot authenticate API or MCP requests. |
| Bucket S3 credentials | S3-compatible clients | Access to that bucket; obtain them through the bucket tooling and keep them server-side. |
| Secret reference | Configuration inside an app | Selects a stored application secret; it is not a credential for managing the platform. |
Keep automation credentials in the CI system’s secret store. Disable shell tracing around credentials and avoid printing tokens or authenticated Git URLs. Use a deployment token when a job only needs to push source.
Make an authenticated read request
Sign in locally with the CLI first:
paas login --host https://dashboard.d2o.io
Then request your account details without printing the token:
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $(paas auth token)" \
https://api.d2o.io/api/v1/users/me
In CI, supply PAAS_API_URL=https://api.d2o.io and PAAS_API_TOKEN from the secret store, then use a CLI command such as paas whoami --json. Never commit the token. See CLI authentication for host selection and configuration.
Handle errors and collection results
Treat a 401 response as an authentication problem and 403 as a permission problem. Check the error response before retrying a failed write. For 429, wait for the response’s Retry-After interval and back off; see API rate limits.
Do not assume every list returns all records or uses the same pagination scheme. Follow the specific endpoint’s documentation; for example, audit logs use limit and offset. A successful Git push only confirms source acceptance; deployment verification is a separate step.
For external AI clients, use the MCP setup guide.
Plans, Limits & Usage
Every organization is on a plan. The plan sets how much capacity the organization can use, the dashboard shows you where you stand, and the platform enforces limits only when you try to create or grow something — never by taking anything away.
Free organizations
The Free plan has no resource capacity. You can create an organization and manage its settings and members, but you cannot create projects, environments, apps, workers, databases or object-storage buckets in a Free organization. Resource-capacity overrides do not change this; the organization needs another plan.
Existing resources are preserved. You can maintain them, reduce their capacity and deploy new versions without increasing their reserved capacity. Their monthly transfer allowances are unchanged.
The destination organization’s plan applies. A Free personal organization does not restrict your permitted work in another organization on an eligible plan.
How limits work
Limits are capacity pools, tracked separately for each environment type — Development, Staging, and Production. Each pool covers six dimensions:
- CPU — the CPU reserved by your Web Applications and Background Workers, per instance, and by your Databases.
- Memory — the memory they reserve, per instance, and your Databases’ memory.
- Database storage — the storage size of your Databases.
- Object storage — capacity — the quota of your Capacity-tier Object Storage buckets.
- Object storage — HA — the quota of your High-availability-tier Object Storage buckets.
- Egress rate — the outbound network rate reserved by your apps, per instance.
What counts is what your resources declare, not what they happen to be doing right now. A Web Application scaled to three instances counts its CPU and memory limits three times; with autoscaling enabled, the maximum instance count is what’s counted, since the platform may scale you up to it. Apps that don’t set explicit CPU or memory limits count at the platform’s default size. Each Database counts 500m CPU (half a core) and 512 MiB of memory, and its storage size — 1 GB when created without one. A dimension shown as Unlimited has no cap on your plan. A PostgreSQL database with high availability turned on counts twice against CPU, memory, and database storage, since it runs two full copies.
Viewing your usage
Any organization member can view usage in the dashboard. The Usage tab appears at every level, each scoped to that object:
- Organization Settings → Usage — your current plan, and for each environment type a bar per dimension with current usage against the limit (the bar turns red when a pool is full). Environment types your plan doesn’t include show as Not available on your plan.
- Project → Settings → Usage — the same capacity pools, counted for that project only.
- Environment → Settings → Usage — the environment’s share of its type’s org-wide pool.
- Resource → Usage (apps, workers and databases) — the resource’s bandwidth this month against your organization’s allocation, plus its live CPU, memory and, for web apps, HTTP metrics.
There is no CLI command for usage; the dashboard is the place to check.
When you hit a limit
Limits are checked when you write: creating a resource or environment, scaling up, raising an app’s CPU or memory limits, or increasing a bucket quota. A change that would push a pool past its cap is rejected with a clear error naming the environment type, the dimension, the cap, and what the change would have made the total — for example, that a scale-up would exceed the production pool’s CPU. The dashboard shows the message inline; the CLI prints the same error. Over the API this is a 403 with code: "LIMIT_EXCEEDED", the dimension in limit (for example cpu_millicores), the cap in max and the would-be total in current.
Nothing partial happens: the rejected change simply doesn’t apply, and everything already running keeps running.
If your plan doesn’t include an environment type at all, creating an environment of that type is rejected with a message saying your plan provides no capacity for it.
Going over a limit never deletes anything
Enforcement is write-only. If your usage ends up above a limit — for instance, after a plan change — the platform never deletes, stops, or scales down anything you have. Existing resources keep running exactly as they are; you just can’t grow further in the affected pool until you free capacity or the limit changes.
To free capacity yourself: scale an app down, lower its CPU/memory limits, or delete resources you no longer need.
Changing your plan
Plans are managed by the platform team. The platform is invitation-only, so you already have a contact there — reach out to them to move to a different plan or discuss a limit. Per-organization adjustments to individual limits are possible on plans with resource capacity. Free organizations need another plan before they can add resources.
Paid services and outage adjustments
Capacity limits describe what you may provision; they are not an invoice or a price offer. Any paid arrangement must separately specify and agree the rates, metering, payment, and cancellation terms. Complimentary accounts do not automatically become paid accounts.
For qualifying outages affecting paid services, d2o applies prorated adjustments to the affected service charges without requiring a customer claim for an outage it identifies. Unpaid charges are reduced; amounts already paid become credits that can be refunded on request. Monthly minimums do not cancel an adjustment. Complimentary services receive no monetary adjustment.
The Outage Adjustments Policy explains eligibility, calculations, timing, and how to report a missed outage. The Terms of Service incorporate that policy and address lost revenue and other business losses. No minimum uptime percentage is promised unless expressly agreed in writing. An individually agreed written service agreement controls where it expressly conflicts, subject to applicable law.
Monthly transfer allocation
Separate from the egress rate above, your organization also has a monthly transfer allocation: a budget, in GB, for the actual public network traffic your Web Applications send and receive over the course of a calendar month. Egress rate caps how fast one instance may push data at any given moment (checked when you set it); the monthly transfer allocation caps how much data moves in total, metered hourly against real traffic rather than reserved capacity, so figures can lag by up to an hour.
What’s counted. Only public traffic counts: requests reaching your Web Applications from the internet, and the responses they send back. Traffic that never leaves the platform — an app talking to its attached database, for instance — is not counted, and Background Workers and Databases don’t take public traffic, so they never add to the total. The “Usage” tabs described above (Organization, Project, Environment, and Resource) each show a Bandwidth this month figure alongside your allocation.
Alerts. As usage climbs, your organization’s owner gets an email at 80%, 95%, and 100% (your installation’s defaults) of the monthly allocation — and of any project, environment or resource budget set for you — each threshold fires once per month. These are notifications only; nothing is affected yet.
Going over the allocation. If usage for an Organization, Project, Environment, or an individual Resource is still over its budget the next time the platform checks (about a minute later, to rule out a one-off blip), every Web Application and Background Worker covered by that budget is scaled to zero replicas, and every public Object Storage bucket covered by it is switched to private — until usage is back under budget. This is the one exception to “Going over a limit never deletes anything” above: because this dimension meters real traffic rather than declared capacity, exceeding it does stop resources. Blocked resources show it plainly, so it’s never mistaken for a crash: the dashboard shows an app or worker as Blocked and a public bucket as Private — blocked; the CLI appends “(blocked - over monthly transfer allocation)” to the status and shows the bucket as “private (blocked; public once unblocked)”. The owner gets an email when this happens, and another once everything is restored. Databases are never affected. See My app scaled to zero, or my bucket stopped serving publicly if this happens to you.
Getting back under budget. Usage resets to zero at the start of each calendar month, which lifts the block automatically. Whoever operates your platform installation can also raise or remove the allocation for your organization (or a specific project, environment, or resource) sooner — see Changing your plan.
API rate limits
Separately from plan limits, the API limits how many requests each client IP address can make. Normal dashboard and CLI use never comes close; a script can.
| Requests | Limit per client |
|---|---|
| Sign-in and CLI login, invitation lookups, deployments, console sessions and one-off commands, domain verification and release, public bucket lookups | About 10 per second, bursts up to 20 |
| Everything else | About 50 per second, bursts up to 50 |
| The in-dashboard assistant (per user) | About 1 every 3 seconds, bursts up to 6 |
A request over the limit gets 429 Too Many Requests with a Retry-After header saying how many seconds to wait. Nothing else happens — no lockout — so a client that waits and slows down continues normally. A rate-limited response looks like this:
HTTP/1.1 429 Too Many Requests
retry-after: 1
rate limit exceeded
Next steps
- Organizations, Projects & Environments — environment types and where limits attach.
- Scaling & Autoscaling — instance counts and app CPU/memory limits.
- Managing Your Organization — the rest of Organization Settings.
- Troubleshooting & FAQ — what to do when a create or scale is rejected.
d2o-paas.yaml Reference
d2o-paas.yaml lets a Web Application or Background Worker carry some of its
settings in its own repository instead of the dashboard or CLI. The settings
that depend on your code — a health check path your code answers, a release
command your code needs run before it starts — travel with the version that
needs them. A rollback then brings back the code and the settings it was
built for, together.
The file is optional. An app with no d2o-paas.yaml works exactly as it does
today, reading these same settings from the dashboard or the CLI.
Where the file lives
d2o-paas.yaml sits at the root of whatever gets built:
- the root of your repository; or
- for an app built from a subdirectory (
--source-dir), that directory.
An app built from a subdirectory only sees the file in its own directory — the same rule that already applies to its Procfile.
Fields
| Field | Where | Meaning |
|---|---|---|
version | top | The file’s format version. Optional — leaving it out means 1, the only version this reference describes. |
release.command | top only | A command that runs once against each new version before anything else starts. See Release command. |
process | top, or apps.<name> | Which Procfile process the app runs. |
port | top, or apps.<name> | The port the app listens on. Only needed for an app that listens on a fixed port instead of taking the one the platform assigns — most apps don’t need this. |
health_check.path | top, or apps.<name> | A path the platform requests to confirm a new version is up, same as the app’s Health Check Path setting. |
health_check.command | top, or apps.<name> | A command that must exit 0 for a new version to count as healthy. Background Workers only. |
auto_rollback | top, or apps.<name> | Whether a version that never becomes healthy is automatically replaced with the last healthy one. |
singleton | top, or apps.<name> | Whether the app runs at most one instance. See Singleton apps. |
Set exactly one of health_check.path or health_check.command for a given
app — not both.
Defaults and per-app overrides
health_check:
path: /health
apps:
worker:
health_check:
command: bin/worker-check
Top-level values are defaults for every app built from the file. An
apps.<name> block overrides them for one app, field by field — <name> is
the app’s name in the Environment. A repository that builds a single app can
leave out apps: entirely and put everything at the top level.
release.command is the one exception: it only ever belongs at the top
level, never inside apps.<name>. For a shared build that produces more than
one app, it still runs only once, before the one app it’s attached to starts.
What’s not in this file
Settings that differ per Environment, or that touch your plan or your secrets, are never set here — they stay in the dashboard or the CLI:
- replica count, CPU, and memory;
- custom domains;
- environment variables and secrets.
Checking a file
paas manifest check validates a d2o-paas.yaml file against an
Environment’s apps before you push, so a typo is caught before it fails a
deploy:
paas manifest check -o acme -p shop -e production
$ paas manifest check -o acme -p shop -e production
d2o-paas.yaml is valid for production
It prints errors the same way a failed deployment would, warnings as warning: … lines, and exits 1 if the file is invalid — so it’s a reasonable check
to run in CI before pushing. Point it at a specific file with a path
argument; without one, it reads d2o-paas.yaml from the current directory.
If the file can’t be read, it prints no d2o-paas.yaml here and exits 2, so
CI can tell a missing file from an invalid one.
Errors
An invalid file fails validation outright. Every error names the line and the field:
d2o-paas.yaml line 3: unknown field `heath_check`
-
Unknown fields are errors — a typo in a field name is never silently ignored.
-
environments,databases,buckets, andservicesare reserved for future use and give their own message:d2o-paas.yaml line 1: `environments` is not supported yet -
An unsupported
versiongives:d2o-paas.yaml line 1: version 2 isn't supported; use 1 or leave it out -
Beyond that, the same rules as the matching app settings apply: a health check path starts with
/and is at most 100 characters; a health check command is 1–500 characters and only valid for Background Workers; a port is between 1 and 65535;singletonistrueorfalse; a release command is 1–1000 characters; aprocessname uses only letters, digits,-and_(at most 63 characters). -
The file itself must be at most 16 KiB.
On push, an invalid file fails the deployment immediately — no build starts,
and the error is printed as remote: lines on the push, the same way a
rejected push reports any other problem.
A promotion, a manual deployment (paas deploy), or a rollback with
paas rollback --to refuses a version whose file is invalid: that deployment
fails before its release command runs, and nothing already running is
touched:
Your d2o-paas.yaml is invalid: line 3: unknown field `heath_check`
Rolling back a deployment — paas deploys rollback, the deployment page’s
Roll back button, or an automatic rollback — never fails over the file:
putting the previous version back must not be blocked. If that version’s
file doesn’t validate, it goes back with the app’s settings left as they
are.
Warnings
Warnings appear on the deployment timeline but never fail it:
-
an
apps.<name>entry doesn’t match any app in this Environment — expected if the same file is shared across Environments with different apps, for example:d2o-paas.yaml has settings for `ghost`, which isn't an app in this environment -
process: <name>doesn’t match any line in the Procfile; -
the Procfile’s
release:line overrides the app’s Release command setting — see Release command; -
singleton: trueis set while the app’s own settings ask for more than one instance, or for autoscaling — the version deploys as a singleton anyway (one instance, autoscaling off), and the warning says so.
Read-only settings
A field the running version’s d2o-paas.yaml (or Procfile, for the release
command only) sets can’t be changed from the dashboard or CLI while that
version is live:
-
In the dashboard, the field is read-only, shows the value that runs, and is marked “from d2o-paas.yaml at
abc1234” (or “from Procfile”). Under it, “Used if your code stops setting this” shows the app’s own setting. -
paas apps infoshows the value that runs, followed by where it comes from. -
From the CLI, changing it fails:
`health_check` is set in d2o-paas.yaml at abc1234; change it there`release_command` is set by the Procfile's `release:` line; change it thereA file that sets
singleton: truealso blocks scaling past one instance:this app is a singleton because d2o-paas.yaml sets `singleton: true`, so it runs one instance; change it there to run more
Push a version whose file no longer sets that field, and the dashboard or
CLI setting applies again immediately. Before your first deployment with a
d2o-paas.yaml in it, nothing is locked yet.
Rollbacks
A rollback restores the settings of the version it goes back to, exactly as
they were built — its d2o-paas.yaml, its Procfile, both together. If the
version you’re rolling back to had no file, rolling back also hands any
locked fields back to the dashboard or CLI setting.
Example
release: # top level only: belongs to the codebase
command: bin/migrate
health_check: # top-level values are defaults for every app built from this code
path: /health
apps: # per-app values, keyed by the app's name in the environment
worker:
process: worker # Procfile process to run
health_check:
command: bin/worker-check
singleton: true
Next steps
- Procfile — what runs, and how an app picks a process
- Move Settings Into Your Repo — a worked walkthrough
- Deployments, Health Checks & Rollback — health checks and the release command in full
- Deploying with Git Push
Procfile
A Procfile is a plain text file at the root of your repository (or, for an
app built from a subdirectory, that directory) that names the process types
your code can run. It’s supported for every framework, so it works whatever
language your app is written in.
Format
One line per process type, <name>: <command>:
web: bundle exec puma -p $PORT
worker: bundle exec sidekiq
release: bin/rails db:migrate
web and worker are conventional names, matching the two kinds of app the
platform runs, but a process type can be named anything — whichever name
matches what the app it belongs to needs.
Which process an app runs
When a build produces more than one process type, each app built from it picks one to run. The platform decides, in order:
process:in that app’sd2o-paas.yaml, if present;- the app’s Process setting (
--processin the CLI, the Process field in the dashboard — formerly called “Start command”), if set; - the Procfile line whose name matches the app’s own name — an app named
workerruns theworker:line automatically, with nothing to configure; web, if none of the above apply.
Name matching (step 3) is a convenience, not something you declare — an
explicit process: or Process setting always wins over it.
Name matching applies to apps created from now on only. An app that
already existed before this feature shipped keeps running whatever process it
runs today, even if its name matches a Procfile line — so an existing worker
app with a worker: line in its Procfile doesn’t silently switch what it
runs. To opt such an app into name matching, clear its Process setting:
paas apps update worker --process "" -o acme -p shop -e production
The release process
A line named release: is special — it isn’t a process the platform keeps
running. Instead, it runs once against each new version, before any instance
of the app starts, exactly like the app’s Release command setting. See
Release command for the full
behavior: when it runs, what happens if it fails, and why it should be safe
to run more than once.
The Procfile’s release: line takes precedence over the app’s Release
command setting. If both are present, release: runs and the setting is
ignored — the deployment timeline says so:
Your app's release command setting is ignored because the Procfile defines `release:`
This is the one behavior change for apps that already have both: previously
the app setting won; now the Procfile does. To go back to using the app
setting, remove the release: line from the Procfile.
Every language is supported
The Procfile is read for Ruby on Rails, Node.js, Python, Go and Rust apps
(a Static Files site has no processes of its own). Python apps must have one: the Python build has no
default start command, so a Python app without a Procfile fails to build.
For the others you don’t need one if the framework already knows how to
start your app (for example, a Rails app’s default web process); add one
when your build produces more than one process type, or to name a release
command.
Next steps
- d2o-paas.yaml Reference — the file that says how the platform treats what the Procfile runs
- Deployments, Health Checks & Rollback — the release command and health checks in full
- One remote for several apps — several apps, one Procfile
Supported Frameworks
Every Web Application and Background Worker declares a framework: the language or stack your code is written in. The framework selects the buildpack that turns your source into a runnable image — there is no Dockerfile, and no build happens without one, so the platform rejects any attempt to create an app without a supported framework with a validation error.
The framework list
These are the frameworks offered in the dashboard’s Create Resource form. The
CLI value is what you pass to --framework.
| Framework | CLI value | Builds |
|---|---|---|
| Ruby on Rails | rails | Ruby / Rails applications |
| Node.js | nodejs | Node.js applications |
| Python | python | Python applications |
| Go | go | Go applications |
| Rust | rust | Rust applications |
| Static Files | static | Static sites (HTML, CSS, JavaScript), served by nginx |
Each build needs a few files at the app’s root; without them it fails right away:
- Python — a
Procfilenaming at least aweb(orworker) process — see Procfile. - Rust —
Cargo.tomlandCargo.lock. Each[[bin]]target becomes a process type you can pick with the app’s Process setting. - Node.js —
package.json. - Ruby on Rails —
GemfileandGemfile.lock. - Go —
go.mod. The main package becomes thewebprocess, so a single-binary app needs no Procfile; add one to run something else. - Static Files — your site’s files in a
public/directory, withpublic/index.htmlas the home page. They’re served by nginx on$PORT. A static site has no/healthpage, so set the Health Check Path to/(new static web apps default to this when no explicit path is provided).
The framework does not have to match a specific web framework by name — a
plain Express, Flask, or Axum app is just nodejs, python, or rust. Pick
the language your code runs in; Static Files is for sites that are just
files to serve, with no server-side runtime of their own.
Why it is required
The build pipeline uses the framework to choose which buildpack builds your commit. Without a valid framework there is nothing to build with, and the app would sit in a provisioning state forever waiting for an image that never arrives. The platform fails fast instead: creating or updating a Web Application or Background Worker without a supported framework is rejected with a validation error:
framework is required for web_app/worker resources (supported: nodejs, rails, python, go, rust, static)
A value that isn’t on the list at all — a typo like node — is rejected
before that check, naming the field and the values it accepts:
Invalid JSON: Failed to deserialize the JSON body into the target type: framework: unknown variant `node`, expected one of `rails`, `nodejs`, `python`, `go`, `rust`, `static` at line 1 column …
Use the CLI values in the table above.
Setting the framework
In the dashboard, the Create Resource form shows a Framework dropdown whenever you pick the Web Application or Background Worker type. Choose one before creating the app.
With the CLI, pass --framework to paas apps create:
paas apps create web \
-o acme -p shop -e production \
--framework nodejs \
--port 3000
You can change it later with paas apps update, for example if you rewrote
the service in another language:
paas apps update web -o acme -p shop -e production --framework rust
The new framework takes effect on the next build.
Language version selection
The framework selects a builder; version declarations in your repository select its runtime. See Selecting Runtime Versions for exact pins, ranges, defaults, Ruby lockfiles, Go toolchain selection, and Rust channels. The Runtime Versions catalog separates downloadable releases from upstream maintenance and d2o verification.
Static Files can also build frontend assets with npm or Yarn before serving them. Its build-time Node.js inventory differs from the Node.js application builder. Go applications build directly from Go modules.
Next steps
- How Builds & Deployments Work
- Creating a Web Application
- Deploying with Git Push
- Troubleshooting & FAQ
Runnable examples
The language tutorials cover all six frameworks, with source downloads, dependency locks, health endpoints, database adapters, workers, and storage.
Selecting Runtime Versions
The framework selects a builder. Your repository selects the language version. The version catalog lists installable versions separately from upstream maintenance status and d2o verification, for production and staging. An upstream release is not necessarily available in the deployed builder yet.
Put version files at the app root: the repository root, or the selected source directory for a shared repository. Commit them together with dependency lockfiles. Exact pins improve repeatability; ranges let later builds select newer matching releases. A removed or incompatible version fails the build.
Node.js
Set engines.node in package.json:
{
"engines": { "node": "24.x" },
"scripts": { "start": "node server.js" }
}
24.x selects an available patch in that major line. An exact version, such as
24.0.0, requests that release; check its availability and support status before
using it. Without a requirement, the Heroku Node.js buildpack selects its default
LTS line. Defaults belong to the deployed buildpack and may change on an update.
Commit the package manager’s lockfile too.
Static frontend compilation uses Paketo’s Node engine, with its own inventory
and defaults. It accepts BP_NODE_VERSION, then package.json engines, .nvmrc,
and .node-version, in that order. Do not assume its versions match the Node.js
application builder. See the static site guide.
Ruby and Rails
Use the rails framework for Ruby applications, including non-Rails applications.
Declare Ruby in your Gemfile and update the lockfile:
source "https://rubygems.org"
ruby "3.3.9"
bundle update --ruby
git add Gemfile Gemfile.lock
The buildpack installs the version resolved in Gemfile.lock’s RUBY VERSION
section. Changing only the Gemfile does not update that recorded version.
An omitted lockfile version uses the buildpack default. A .ruby-version file
can supply the Gemfile declaration via Bundler’s ruby file: ".ruby-version";
commit the updated lockfile as well. The catalog shows which exact releases can
be downloaded for d2o’s stack.
Python
Commit .python-version:
3.14
A minor version uses the latest patch mapped by the deployed buildpack. An exact
patch requests that artifact, provided the buildpack accepts the minor line and
the artifact remains downloadable. Older downloadable patches are not necessarily
maintained or verified. Without this file, the buildpack normally selects its
default; projects using uv may require the file explicitly. Use the existing
dependency manager’s lockfile and a Procfile for the web/worker
command. Bind a web server to 0.0.0.0 and the injected PORT.
Go
The Heroku Go builder reads go.mod:
module example.com/service
go 1.26
A two-component directive selects an available patch in that minor line. A three-component directive selects that exact version in this buildpack. For an explicit buildpack requirement, use its supported comment:
// +heroku goVersion =1.26.0
That comment takes precedence over go. The deployed buildpack’s parser does
not use the toolchain directive to select its initial compiler. The Go command
can independently select/download another toolchain under Go’s GOTOOLCHAIN
rules, so keep declarations consistent and check the build log. When no version
is requested, the buildpack selects from its inventory. See deploying Go.
Rust
Commit Cargo.toml, Cargo.lock, and optionally rust-toolchain.toml:
[toolchain]
channel = "1.90.0"
profile = "minimal"
The version above illustrates an exact pin; use the catalog to choose a maintained
release. The Paketo community Rust builder uses rustup and defaults to stable.
The toolchain file can select an exact stable release, stable, beta, nightly,
or a dated nightly such as nightly-2026-09-01. Channel names move over time;
an exact or dated toolchain must still be available for the builder’s target.
Beta and nightly are experimental, and d2o does not test every historical nightly.
package.rust-version in Cargo.toml declares the minimum supported Rust
version for your package; it does not pin the compiler. For multiple binaries,
select the generated process type in the app’s Process setting.
Updates and rebuilds
Runtime versions, buildpack versions, and operating-system stacks are separate. kpack can rebuild an unchanged application commit when a buildpack or stack changes. Such a rebuild can select a newer matching runtime when your requirement is a range or a channel. Pinning a runtime does not freeze the builder or OS.
A failed image build never becomes a running version. A built version must then pass its deployment health checks. Automatic rollback restores the previous image, not database or object-storage contents.
The catalog is refreshed daily and after builder promotions. Its tested status applies to a small build-and-HTTP fixture on the recorded builder digest, not to every dependency an application could use. Historical Rust releases and end-of-life language versions may remain downloadable without being supported.
Upstream references
- Heroku Node.js
- Heroku Ruby
- Heroku Python
- Heroku Go
- Paketo Rustup
- Cargo’s rust-version
- Paketo Node.js
Runtime Versions
How to select a version. Availability means the builder’s resolver and a downloadable artifact permit installation; it is not a compatibility guarantee.
Supported means upstream-maintained and smoke-tested on this exact builder. Available but untested and end-of-life releases are listed separately. Rust beta/nightly selectors are experimental.
Upstream status describes maintenance of the release line. It does not mean an older patch contains the latest fixes; prefer the newest verified patch in that line. Bundled npm is reported for the static frontend toolchain.
Catalog changes and verification dates are recorded below. Daily refresh health is tracked by the runtime-catalog CI job; a failed refresh preserves the last verified snapshot.
Production
heroku-go
Stack: heroku-22 · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/heroku-go:clusterbuilder-heroku-go@sha256:25e9eef77d870bc0f3aadeb0620d940b95b3eb9cecd852c0d29a50374c937dd5
Buildpacks: heroku/go@4.1.1, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: latest inventory release when go.mod omits go; otherwise its requirement.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| go | 1.26.8 | — | maintained | passed 2026-10-06 | yes |
| go | 1.27.1 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| go | 1.11 | — | end-of-life | untested | no |
| go | 1.11.1 | — | end-of-life | untested | no |
| go | 1.11.2 | — | end-of-life | untested | no |
| go | 1.11.3 | — | end-of-life | untested | no |
| go | 1.11.4 | — | end-of-life | untested | no |
| go | 1.11.5 | — | end-of-life | untested | no |
| go | 1.11.6 | — | end-of-life | untested | no |
| go | 1.11.7 | — | end-of-life | untested | no |
| go | 1.11.8 | — | end-of-life | untested | no |
| go | 1.11.9 | — | end-of-life | untested | no |
| go | 1.11.10 | — | end-of-life | untested | no |
| go | 1.11.11 | — | end-of-life | untested | no |
| go | 1.11.12 | — | end-of-life | untested | no |
| go | 1.11.13 | — | end-of-life | untested | no |
| go | 1.12 | — | end-of-life | untested | no |
| go | 1.12.1 | — | end-of-life | untested | no |
| go | 1.12.2 | — | end-of-life | untested | no |
| go | 1.12.3 | — | end-of-life | untested | no |
| go | 1.12.4 | — | end-of-life | untested | no |
| go | 1.12.5 | — | end-of-life | untested | no |
| go | 1.12.6 | — | end-of-life | untested | no |
| go | 1.12.7 | — | end-of-life | untested | no |
| go | 1.12.8 | — | end-of-life | untested | no |
| go | 1.12.9 | — | end-of-life | untested | no |
| go | 1.12.10 | — | end-of-life | untested | no |
| go | 1.12.11 | — | end-of-life | untested | no |
| go | 1.12.12 | — | end-of-life | untested | no |
| go | 1.12.13 | — | end-of-life | untested | no |
| go | 1.12.14 | — | end-of-life | untested | no |
| go | 1.12.15 | — | end-of-life | untested | no |
| go | 1.12.16 | — | end-of-life | untested | no |
| go | 1.12.17 | — | end-of-life | untested | no |
| go | 1.13 | — | end-of-life | untested | no |
| go | 1.13.1 | — | end-of-life | untested | no |
| go | 1.13.2 | — | end-of-life | untested | no |
| go | 1.13.3 | — | end-of-life | untested | no |
| go | 1.13.4 | — | end-of-life | untested | no |
| go | 1.13.5 | — | end-of-life | untested | no |
| go | 1.13.6 | — | end-of-life | untested | no |
| go | 1.13.7 | — | end-of-life | untested | no |
| go | 1.13.8 | — | end-of-life | untested | no |
| go | 1.13.9 | — | end-of-life | untested | no |
| go | 1.13.10 | — | end-of-life | untested | no |
| go | 1.13.11 | — | end-of-life | untested | no |
| go | 1.13.12 | — | end-of-life | untested | no |
| go | 1.13.13 | — | end-of-life | untested | no |
| go | 1.13.14 | — | end-of-life | untested | no |
| go | 1.13.15 | — | end-of-life | untested | no |
| go | 1.14 | — | end-of-life | untested | no |
| go | 1.14.1 | — | end-of-life | untested | no |
| go | 1.14.2 | — | end-of-life | untested | no |
| go | 1.14.3 | — | end-of-life | untested | no |
| go | 1.14.4 | — | end-of-life | untested | no |
| go | 1.14.5 | — | end-of-life | untested | no |
| go | 1.14.6 | — | end-of-life | untested | no |
| go | 1.14.7 | — | end-of-life | untested | no |
| go | 1.14.8 | — | end-of-life | untested | no |
| go | 1.14.9 | — | end-of-life | untested | no |
| go | 1.14.10 | — | end-of-life | untested | no |
| go | 1.14.11 | — | end-of-life | untested | no |
| go | 1.14.12 | — | end-of-life | untested | no |
| go | 1.14.13 | — | end-of-life | untested | no |
| go | 1.14.14 | — | end-of-life | untested | no |
| go | 1.14.15 | — | end-of-life | untested | no |
| go | 1.15 | — | end-of-life | untested | no |
| go | 1.15.1 | — | end-of-life | untested | no |
| go | 1.15.2 | — | end-of-life | untested | no |
| go | 1.15.3 | — | end-of-life | untested | no |
| go | 1.15.4 | — | end-of-life | untested | no |
| go | 1.15.5 | — | end-of-life | untested | no |
| go | 1.15.6 | — | end-of-life | untested | no |
| go | 1.15.7 | — | end-of-life | untested | no |
| go | 1.15.8 | — | end-of-life | untested | no |
| go | 1.15.9 | — | end-of-life | untested | no |
| go | 1.15.10 | — | end-of-life | untested | no |
| go | 1.15.11 | — | end-of-life | untested | no |
| go | 1.15.12 | — | end-of-life | untested | no |
| go | 1.15.13 | — | end-of-life | untested | no |
| go | 1.15.14 | — | end-of-life | untested | no |
| go | 1.15.15 | — | end-of-life | untested | no |
| go | 1.16 | — | end-of-life | untested | no |
| go | 1.16.1 | — | end-of-life | untested | no |
| go | 1.16.2 | — | end-of-life | untested | no |
| go | 1.16.3 | — | end-of-life | untested | no |
| go | 1.16.4 | — | end-of-life | untested | no |
| go | 1.16.5 | — | end-of-life | untested | no |
| go | 1.16.6 | — | end-of-life | untested | no |
| go | 1.16.7 | — | end-of-life | untested | no |
| go | 1.16.8 | — | end-of-life | untested | no |
| go | 1.16.9 | — | end-of-life | untested | no |
| go | 1.16.10 | — | end-of-life | untested | no |
| go | 1.16.11 | — | end-of-life | untested | no |
| go | 1.16.12 | — | end-of-life | untested | no |
| go | 1.16.13 | — | end-of-life | untested | no |
| go | 1.16.14 | — | end-of-life | untested | no |
| go | 1.16.15 | — | end-of-life | untested | no |
| go | 1.17 | — | end-of-life | untested | no |
| go | 1.17.1 | — | end-of-life | untested | no |
| go | 1.17.2 | — | end-of-life | untested | no |
| go | 1.17.3 | — | end-of-life | untested | no |
| go | 1.17.4 | — | end-of-life | untested | no |
| go | 1.17.5 | — | end-of-life | untested | no |
| go | 1.17.6 | — | end-of-life | untested | no |
| go | 1.17.7 | — | end-of-life | untested | no |
| go | 1.17.8 | — | end-of-life | untested | no |
| go | 1.17.9 | — | end-of-life | untested | no |
| go | 1.17.10 | — | end-of-life | untested | no |
| go | 1.17.11 | — | end-of-life | untested | no |
| go | 1.17.12 | — | end-of-life | untested | no |
| go | 1.17.13 | — | end-of-life | untested | no |
| go | 1.18 | — | end-of-life | untested | no |
| go | 1.18.1 | — | end-of-life | untested | no |
| go | 1.18.2 | — | end-of-life | untested | no |
| go | 1.18.3 | — | end-of-life | untested | no |
| go | 1.18.4 | — | end-of-life | untested | no |
| go | 1.18.5 | — | end-of-life | untested | no |
| go | 1.18.6 | — | end-of-life | untested | no |
| go | 1.18.7 | — | end-of-life | untested | no |
| go | 1.18.8 | — | end-of-life | untested | no |
| go | 1.18.9 | — | end-of-life | untested | no |
| go | 1.18.10 | — | end-of-life | untested | no |
| go | 1.19 | — | end-of-life | untested | no |
| go | 1.19.1 | — | end-of-life | untested | no |
| go | 1.19.2 | — | end-of-life | untested | no |
| go | 1.19.3 | — | end-of-life | untested | no |
| go | 1.19.4 | — | end-of-life | untested | no |
| go | 1.19.5 | — | end-of-life | untested | no |
| go | 1.19.6 | — | end-of-life | untested | no |
| go | 1.19.7 | — | end-of-life | untested | no |
| go | 1.19.8 | — | end-of-life | untested | no |
| go | 1.19.9 | — | end-of-life | untested | no |
| go | 1.19.10 | — | end-of-life | untested | no |
| go | 1.19.11 | — | end-of-life | untested | no |
| go | 1.19.12 | — | end-of-life | untested | no |
| go | 1.19.13 | — | end-of-life | untested | no |
| go | 1.20 | — | end-of-life | untested | no |
| go | 1.20.1 | — | end-of-life | untested | no |
| go | 1.20.2 | — | end-of-life | untested | no |
| go | 1.20.3 | — | end-of-life | untested | no |
| go | 1.20.4 | — | end-of-life | untested | no |
| go | 1.20.5 | — | end-of-life | untested | no |
| go | 1.20.6 | — | end-of-life | untested | no |
| go | 1.20.7 | — | end-of-life | untested | no |
| go | 1.20.8 | — | end-of-life | untested | no |
| go | 1.20.9 | — | end-of-life | untested | no |
| go | 1.20.10 | — | end-of-life | untested | no |
| go | 1.20.11 | — | end-of-life | untested | no |
| go | 1.20.12 | — | end-of-life | untested | no |
| go | 1.20.13 | — | end-of-life | untested | no |
| go | 1.20.14 | — | end-of-life | untested | no |
| go | 1.21.0 | — | end-of-life | untested | no |
| go | 1.21.1 | — | end-of-life | untested | no |
| go | 1.21.2 | — | end-of-life | untested | no |
| go | 1.21.3 | — | end-of-life | untested | no |
| go | 1.21.4 | — | end-of-life | untested | no |
| go | 1.21.5 | — | end-of-life | untested | no |
| go | 1.21.6 | — | end-of-life | untested | no |
| go | 1.21.7 | — | end-of-life | untested | no |
| go | 1.21.8 | — | end-of-life | untested | no |
| go | 1.21.9 | — | end-of-life | untested | no |
| go | 1.21.10 | — | end-of-life | untested | no |
| go | 1.21.11 | — | end-of-life | untested | no |
| go | 1.21.12 | — | end-of-life | untested | no |
| go | 1.21.13 | — | end-of-life | untested | no |
| go | 1.22.0 | — | end-of-life | untested | no |
| go | 1.22.1 | — | end-of-life | untested | no |
| go | 1.22.2 | — | end-of-life | untested | no |
| go | 1.22.3 | — | end-of-life | untested | no |
| go | 1.22.4 | — | end-of-life | untested | no |
| go | 1.22.5 | — | end-of-life | untested | no |
| go | 1.22.6 | — | end-of-life | untested | no |
| go | 1.22.7 | — | end-of-life | untested | no |
| go | 1.22.8 | — | end-of-life | untested | no |
| go | 1.22.9 | — | end-of-life | untested | no |
| go | 1.22.10 | — | end-of-life | untested | no |
| go | 1.22.11 | — | end-of-life | untested | no |
| go | 1.22.12 | — | end-of-life | untested | no |
| go | 1.23.0 | — | end-of-life | untested | no |
| go | 1.23.1 | — | end-of-life | untested | no |
| go | 1.23.2 | — | end-of-life | untested | no |
| go | 1.23.3 | — | end-of-life | untested | no |
| go | 1.23.4 | — | end-of-life | untested | no |
| go | 1.23.5 | — | end-of-life | untested | no |
| go | 1.23.6 | — | end-of-life | untested | no |
| go | 1.23.7 | — | end-of-life | untested | no |
| go | 1.23.8 | — | end-of-life | untested | no |
| go | 1.23.9 | — | end-of-life | untested | no |
| go | 1.23.10 | — | end-of-life | untested | no |
| go | 1.23.11 | — | end-of-life | untested | no |
| go | 1.23.12 | — | end-of-life | untested | no |
| go | 1.24.0 | — | end-of-life | untested | no |
| go | 1.24.1 | — | end-of-life | untested | no |
| go | 1.24.2 | — | end-of-life | untested | no |
| go | 1.24.3 | — | end-of-life | untested | no |
| go | 1.24.4 | — | end-of-life | untested | no |
| go | 1.24.5 | — | end-of-life | untested | no |
| go | 1.24.6 | — | end-of-life | untested | no |
| go | 1.24.7 | — | end-of-life | untested | no |
| go | 1.24.8 | — | end-of-life | untested | no |
| go | 1.24.9 | — | end-of-life | untested | no |
| go | 1.24.10 | — | end-of-life | untested | no |
| go | 1.24.11 | — | end-of-life | untested | no |
| go | 1.24.12 | — | end-of-life | untested | no |
| go | 1.24.13 | — | end-of-life | untested | no |
| go | 1.25.0 | — | end-of-life | untested | no |
| go | 1.25.1 | — | end-of-life | untested | no |
| go | 1.25.2 | — | end-of-life | untested | no |
| go | 1.25.3 | — | end-of-life | untested | no |
| go | 1.25.4 | — | end-of-life | untested | no |
| go | 1.25.5 | — | end-of-life | untested | no |
| go | 1.25.6 | — | end-of-life | untested | no |
| go | 1.25.7 | — | end-of-life | untested | no |
| go | 1.25.8 | — | end-of-life | untested | no |
| go | 1.25.9 | — | end-of-life | untested | no |
| go | 1.25.10 | — | end-of-life | untested | no |
| go | 1.25.11 | — | end-of-life | untested | no |
| go | 1.25.12 | — | end-of-life | untested | no |
| go | 1.25.13 | — | end-of-life | untested | no |
| go | 1.25.14 | — | end-of-life | untested | no |
| go | 1.26.0 | — | maintained | untested | no |
| go | 1.26.1 | — | maintained | untested | no |
| go | 1.26.2 | — | maintained | untested | no |
| go | 1.26.3 | — | maintained | untested | no |
| go | 1.26.4 | — | maintained | untested | no |
| go | 1.26.5 | — | maintained | untested | no |
| go | 1.26.6 | — | maintained | untested | no |
| go | 1.26.7 | — | maintained | untested | no |
| go | 1.26.8 | — | maintained | passed 2026-10-06 | yes |
| go | 1.27.0 | — | maintained | untested | no |
| go | 1.27.1 (default) | — | maintained | passed 2026-10-06 | yes |
heroku-nodejs
Stack: heroku-22 · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/heroku-nodejs:clusterbuilder-heroku-nodejs@sha256:4596abe3695aeb14db603b4f0db07e3e1a116671dbb84454790f4209523d88a8
Buildpacks: heroku/nodejs@5.8.1, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 24.x.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nodejs | 22.23.3 | — | maintained | passed 2026-10-06 | yes |
| nodejs | 24.21.0 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 26.10.0 | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nodejs | 0.8.6 | — | unknown | untested | no |
| nodejs | 0.8.7 | — | unknown | untested | no |
| nodejs | 0.8.8 | — | unknown | untested | no |
| nodejs | 0.8.9 | — | unknown | untested | no |
| nodejs | 0.8.10 | — | unknown | untested | no |
| nodejs | 0.8.11 | — | unknown | untested | no |
| nodejs | 0.8.12 | — | unknown | untested | no |
| nodejs | 0.8.13 | — | unknown | untested | no |
| nodejs | 0.8.14 | — | unknown | untested | no |
| nodejs | 0.8.15 | — | unknown | untested | no |
| nodejs | 0.8.16 | — | unknown | untested | no |
| nodejs | 0.8.17 | — | unknown | untested | no |
| nodejs | 0.8.18 | — | unknown | untested | no |
| nodejs | 0.8.19 | — | unknown | untested | no |
| nodejs | 0.8.20 | — | unknown | untested | no |
| nodejs | 0.8.21 | — | unknown | untested | no |
| nodejs | 0.8.22 | — | unknown | untested | no |
| nodejs | 0.8.23 | — | unknown | untested | no |
| nodejs | 0.8.24 | — | unknown | untested | no |
| nodejs | 0.8.25 | — | unknown | untested | no |
| nodejs | 0.8.26 | — | unknown | untested | no |
| nodejs | 0.8.27 | — | unknown | untested | no |
| nodejs | 0.8.28 | — | unknown | untested | no |
| nodejs | 0.9.1 | — | unknown | untested | no |
| nodejs | 0.9.2 | — | unknown | untested | no |
| nodejs | 0.9.3 | — | unknown | untested | no |
| nodejs | 0.9.4 | — | unknown | untested | no |
| nodejs | 0.9.5 | — | unknown | untested | no |
| nodejs | 0.9.6 | — | unknown | untested | no |
| nodejs | 0.9.7 | — | unknown | untested | no |
| nodejs | 0.9.8 | — | unknown | untested | no |
| nodejs | 0.9.9 | — | unknown | untested | no |
| nodejs | 0.9.10 | — | unknown | untested | no |
| nodejs | 0.9.11 | — | unknown | untested | no |
| nodejs | 0.9.12 | — | unknown | untested | no |
| nodejs | 0.10.0 | — | unknown | untested | no |
| nodejs | 0.10.1 | — | unknown | untested | no |
| nodejs | 0.10.2 | — | unknown | untested | no |
| nodejs | 0.10.3 | — | unknown | untested | no |
| nodejs | 0.10.4 | — | unknown | untested | no |
| nodejs | 0.10.5 | — | unknown | untested | no |
| nodejs | 0.10.6 | — | unknown | untested | no |
| nodejs | 0.10.7 | — | unknown | untested | no |
| nodejs | 0.10.8 | — | unknown | untested | no |
| nodejs | 0.10.9 | — | unknown | untested | no |
| nodejs | 0.10.10 | — | unknown | untested | no |
| nodejs | 0.10.11 | — | unknown | untested | no |
| nodejs | 0.10.12 | — | unknown | untested | no |
| nodejs | 0.10.13 | — | unknown | untested | no |
| nodejs | 0.10.14 | — | unknown | untested | no |
| nodejs | 0.10.15 | — | unknown | untested | no |
| nodejs | 0.10.16 | — | unknown | untested | no |
| nodejs | 0.10.17 | — | unknown | untested | no |
| nodejs | 0.10.18 | — | unknown | untested | no |
| nodejs | 0.10.19 | — | unknown | untested | no |
| nodejs | 0.10.20 | — | unknown | untested | no |
| nodejs | 0.10.21 | — | unknown | untested | no |
| nodejs | 0.10.22 | — | unknown | untested | no |
| nodejs | 0.10.23 | — | unknown | untested | no |
| nodejs | 0.10.24 | — | unknown | untested | no |
| nodejs | 0.10.25 | — | unknown | untested | no |
| nodejs | 0.10.26 | — | unknown | untested | no |
| nodejs | 0.10.27 | — | unknown | untested | no |
| nodejs | 0.10.28 | — | unknown | untested | no |
| nodejs | 0.10.29 | — | unknown | untested | no |
| nodejs | 0.10.30 | — | unknown | untested | no |
| nodejs | 0.10.31 | — | unknown | untested | no |
| nodejs | 0.10.32 | — | unknown | untested | no |
| nodejs | 0.10.33 | — | unknown | untested | no |
| nodejs | 0.10.34 | — | unknown | untested | no |
| nodejs | 0.10.35 | — | unknown | untested | no |
| nodejs | 0.10.36 | — | unknown | untested | no |
| nodejs | 0.10.37 | — | unknown | untested | no |
| nodejs | 0.10.38 | — | unknown | untested | no |
| nodejs | 0.10.39 | — | unknown | untested | no |
| nodejs | 0.10.40 | — | unknown | untested | no |
| nodejs | 0.10.41 | — | unknown | untested | no |
| nodejs | 0.10.42 | — | unknown | untested | no |
| nodejs | 0.10.43 | — | unknown | untested | no |
| nodejs | 0.10.44 | — | unknown | untested | no |
| nodejs | 0.10.45 | — | unknown | untested | no |
| nodejs | 0.10.46 | — | unknown | untested | no |
| nodejs | 0.10.47 | — | unknown | untested | no |
| nodejs | 0.10.48 | — | unknown | untested | no |
| nodejs | 0.11.0 | — | unknown | untested | no |
| nodejs | 0.11.1 | — | unknown | untested | no |
| nodejs | 0.11.2 | — | unknown | untested | no |
| nodejs | 0.11.3 | — | unknown | untested | no |
| nodejs | 0.11.4 | — | unknown | untested | no |
| nodejs | 0.11.5 | — | unknown | untested | no |
| nodejs | 0.11.6 | — | unknown | untested | no |
| nodejs | 0.11.7 | — | unknown | untested | no |
| nodejs | 0.11.8 | — | unknown | untested | no |
| nodejs | 0.11.9 | — | unknown | untested | no |
| nodejs | 0.11.10 | — | unknown | untested | no |
| nodejs | 0.11.11 | — | unknown | untested | no |
| nodejs | 0.11.12 | — | unknown | untested | no |
| nodejs | 0.11.13 | — | unknown | untested | no |
| nodejs | 0.11.14 | — | unknown | untested | no |
| nodejs | 0.11.15 | — | unknown | untested | no |
| nodejs | 0.11.16 | — | unknown | untested | no |
| nodejs | 0.12.0 | — | unknown | untested | no |
| nodejs | 0.12.1 | — | unknown | untested | no |
| nodejs | 0.12.2 | — | unknown | untested | no |
| nodejs | 0.12.3 | — | unknown | untested | no |
| nodejs | 0.12.4 | — | unknown | untested | no |
| nodejs | 0.12.5 | — | unknown | untested | no |
| nodejs | 0.12.6 | — | unknown | untested | no |
| nodejs | 0.12.7 | — | unknown | untested | no |
| nodejs | 0.12.8 | — | unknown | untested | no |
| nodejs | 0.12.9 | — | unknown | untested | no |
| nodejs | 0.12.10 | — | unknown | untested | no |
| nodejs | 0.12.11 | — | unknown | untested | no |
| nodejs | 0.12.12 | — | unknown | untested | no |
| nodejs | 0.12.13 | — | unknown | untested | no |
| nodejs | 0.12.14 | — | unknown | untested | no |
| nodejs | 0.12.15 | — | unknown | untested | no |
| nodejs | 0.12.16 | — | unknown | untested | no |
| nodejs | 0.12.17 | — | unknown | untested | no |
| nodejs | 0.12.18 | — | unknown | untested | no |
| nodejs | 4.0.0 | — | end-of-life | untested | no |
| nodejs | 4.1.0 | — | end-of-life | untested | no |
| nodejs | 4.1.1 | — | end-of-life | untested | no |
| nodejs | 4.1.2 | — | end-of-life | untested | no |
| nodejs | 4.2.0 | — | end-of-life | untested | no |
| nodejs | 4.2.1 | — | end-of-life | untested | no |
| nodejs | 4.2.2 | — | end-of-life | untested | no |
| nodejs | 4.2.3 | — | end-of-life | untested | no |
| nodejs | 4.2.4 | — | end-of-life | untested | no |
| nodejs | 4.2.5 | — | end-of-life | untested | no |
| nodejs | 4.2.6 | — | end-of-life | untested | no |
| nodejs | 4.3.0 | — | end-of-life | untested | no |
| nodejs | 4.3.1 | — | end-of-life | untested | no |
| nodejs | 4.3.2 | — | end-of-life | untested | no |
| nodejs | 4.4.0 | — | end-of-life | untested | no |
| nodejs | 4.4.1 | — | end-of-life | untested | no |
| nodejs | 4.4.2 | — | end-of-life | untested | no |
| nodejs | 4.4.3 | — | end-of-life | untested | no |
| nodejs | 4.4.4 | — | end-of-life | untested | no |
| nodejs | 4.4.5 | — | end-of-life | untested | no |
| nodejs | 4.4.6 | — | end-of-life | untested | no |
| nodejs | 4.4.7 | — | end-of-life | untested | no |
| nodejs | 4.5.0 | — | end-of-life | untested | no |
| nodejs | 4.6.0 | — | end-of-life | untested | no |
| nodejs | 4.6.1 | — | end-of-life | untested | no |
| nodejs | 4.6.2 | — | end-of-life | untested | no |
| nodejs | 4.7.0 | — | end-of-life | untested | no |
| nodejs | 4.7.1 | — | end-of-life | untested | no |
| nodejs | 4.7.2 | — | end-of-life | untested | no |
| nodejs | 4.7.3 | — | end-of-life | untested | no |
| nodejs | 4.8.0 | — | end-of-life | untested | no |
| nodejs | 4.8.1 | — | end-of-life | untested | no |
| nodejs | 4.8.2 | — | end-of-life | untested | no |
| nodejs | 4.8.3 | — | end-of-life | untested | no |
| nodejs | 4.8.4 | — | end-of-life | untested | no |
| nodejs | 4.8.5 | — | end-of-life | untested | no |
| nodejs | 4.8.6 | — | end-of-life | untested | no |
| nodejs | 4.8.7 | — | end-of-life | untested | no |
| nodejs | 4.9.0 | — | end-of-life | untested | no |
| nodejs | 4.9.1 | — | end-of-life | untested | no |
| nodejs | 5.0.0 | — | end-of-life | untested | no |
| nodejs | 5.1.0 | — | end-of-life | untested | no |
| nodejs | 5.1.1 | — | end-of-life | untested | no |
| nodejs | 5.2.0 | — | end-of-life | untested | no |
| nodejs | 5.3.0 | — | end-of-life | untested | no |
| nodejs | 5.4.0 | — | end-of-life | untested | no |
| nodejs | 5.4.1 | — | end-of-life | untested | no |
| nodejs | 5.5.0 | — | end-of-life | untested | no |
| nodejs | 5.6.0 | — | end-of-life | untested | no |
| nodejs | 5.7.0 | — | end-of-life | untested | no |
| nodejs | 5.7.1 | — | end-of-life | untested | no |
| nodejs | 5.8.0 | — | end-of-life | untested | no |
| nodejs | 5.9.0 | — | end-of-life | untested | no |
| nodejs | 5.9.1 | — | end-of-life | untested | no |
| nodejs | 5.10.0 | — | end-of-life | untested | no |
| nodejs | 5.10.1 | — | end-of-life | untested | no |
| nodejs | 5.11.0 | — | end-of-life | untested | no |
| nodejs | 5.11.1 | — | end-of-life | untested | no |
| nodejs | 5.12.0 | — | end-of-life | untested | no |
| nodejs | 6.0.0 | — | end-of-life | untested | no |
| nodejs | 6.1.0 | — | end-of-life | untested | no |
| nodejs | 6.2.0 | — | end-of-life | untested | no |
| nodejs | 6.2.1 | — | end-of-life | untested | no |
| nodejs | 6.2.2 | — | end-of-life | untested | no |
| nodejs | 6.3.0 | — | end-of-life | untested | no |
| nodejs | 6.3.1 | — | end-of-life | untested | no |
| nodejs | 6.4.0 | — | end-of-life | untested | no |
| nodejs | 6.5.0 | — | end-of-life | untested | no |
| nodejs | 6.6.0 | — | end-of-life | untested | no |
| nodejs | 6.7.0 | — | end-of-life | untested | no |
| nodejs | 6.8.0 | — | end-of-life | untested | no |
| nodejs | 6.8.1 | — | end-of-life | untested | no |
| nodejs | 6.9.0 | — | end-of-life | untested | no |
| nodejs | 6.9.1 | — | end-of-life | untested | no |
| nodejs | 6.9.2 | — | end-of-life | untested | no |
| nodejs | 6.9.3 | — | end-of-life | untested | no |
| nodejs | 6.9.4 | — | end-of-life | untested | no |
| nodejs | 6.9.5 | — | end-of-life | untested | no |
| nodejs | 6.10.0 | — | end-of-life | untested | no |
| nodejs | 6.10.1 | — | end-of-life | untested | no |
| nodejs | 6.10.2 | — | end-of-life | untested | no |
| nodejs | 6.10.3 | — | end-of-life | untested | no |
| nodejs | 6.11.0 | — | end-of-life | untested | no |
| nodejs | 6.11.1 | — | end-of-life | untested | no |
| nodejs | 6.11.2 | — | end-of-life | untested | no |
| nodejs | 6.11.3 | — | end-of-life | untested | no |
| nodejs | 6.11.4 | — | end-of-life | untested | no |
| nodejs | 6.11.5 | — | end-of-life | untested | no |
| nodejs | 6.12.0 | — | end-of-life | untested | no |
| nodejs | 6.12.1 | — | end-of-life | untested | no |
| nodejs | 6.12.2 | — | end-of-life | untested | no |
| nodejs | 6.12.3 | — | end-of-life | untested | no |
| nodejs | 6.13.0 | — | end-of-life | untested | no |
| nodejs | 6.13.1 | — | end-of-life | untested | no |
| nodejs | 6.14.0 | — | end-of-life | untested | no |
| nodejs | 6.14.1 | — | end-of-life | untested | no |
| nodejs | 6.14.2 | — | end-of-life | untested | no |
| nodejs | 6.14.3 | — | end-of-life | untested | no |
| nodejs | 6.14.4 | — | end-of-life | untested | no |
| nodejs | 6.15.0 | — | end-of-life | untested | no |
| nodejs | 6.15.1 | — | end-of-life | untested | no |
| nodejs | 6.16.0 | — | end-of-life | untested | no |
| nodejs | 6.17.0 | — | end-of-life | untested | no |
| nodejs | 6.17.1 | — | end-of-life | untested | no |
| nodejs | 7.0.0 | — | end-of-life | untested | no |
| nodejs | 7.1.0 | — | end-of-life | untested | no |
| nodejs | 7.2.0 | — | end-of-life | untested | no |
| nodejs | 7.2.1 | — | end-of-life | untested | no |
| nodejs | 7.3.0 | — | end-of-life | untested | no |
| nodejs | 7.4.0 | — | end-of-life | untested | no |
| nodejs | 7.5.0 | — | end-of-life | untested | no |
| nodejs | 7.6.0 | — | end-of-life | untested | no |
| nodejs | 7.7.0 | — | end-of-life | untested | no |
| nodejs | 7.7.1 | — | end-of-life | untested | no |
| nodejs | 7.7.2 | — | end-of-life | untested | no |
| nodejs | 7.7.3 | — | end-of-life | untested | no |
| nodejs | 7.7.4 | — | end-of-life | untested | no |
| nodejs | 7.8.0 | — | end-of-life | untested | no |
| nodejs | 7.9.0 | — | end-of-life | untested | no |
| nodejs | 7.10.0 | — | end-of-life | untested | no |
| nodejs | 7.10.1 | — | end-of-life | untested | no |
| nodejs | 8.0.0 | — | end-of-life | untested | no |
| nodejs | 8.1.0 | — | end-of-life | untested | no |
| nodejs | 8.1.1 | — | end-of-life | untested | no |
| nodejs | 8.1.2 | — | end-of-life | untested | no |
| nodejs | 8.1.3 | — | end-of-life | untested | no |
| nodejs | 8.1.4 | — | end-of-life | untested | no |
| nodejs | 8.2.0 | — | end-of-life | untested | no |
| nodejs | 8.2.1 | — | end-of-life | untested | no |
| nodejs | 8.3.0 | — | end-of-life | untested | no |
| nodejs | 8.4.0 | — | end-of-life | untested | no |
| nodejs | 8.5.0 | — | end-of-life | untested | no |
| nodejs | 8.6.0 | — | end-of-life | untested | no |
| nodejs | 8.7.0 | — | end-of-life | untested | no |
| nodejs | 8.8.0 | — | end-of-life | untested | no |
| nodejs | 8.8.1 | — | end-of-life | untested | no |
| nodejs | 8.9.0 | — | end-of-life | untested | no |
| nodejs | 8.9.1 | — | end-of-life | untested | no |
| nodejs | 8.9.2 | — | end-of-life | untested | no |
| nodejs | 8.9.3 | — | end-of-life | untested | no |
| nodejs | 8.9.4 | — | end-of-life | untested | no |
| nodejs | 8.10.0 | — | end-of-life | untested | no |
| nodejs | 8.11.0 | — | end-of-life | untested | no |
| nodejs | 8.11.1 | — | end-of-life | untested | no |
| nodejs | 8.11.2 | — | end-of-life | untested | no |
| nodejs | 8.11.3 | — | end-of-life | untested | no |
| nodejs | 8.11.4 | — | end-of-life | untested | no |
| nodejs | 8.12.0 | — | end-of-life | untested | no |
| nodejs | 8.13.0 | — | end-of-life | untested | no |
| nodejs | 8.14.0 | — | end-of-life | untested | no |
| nodejs | 8.14.1 | — | end-of-life | untested | no |
| nodejs | 8.15.0 | — | end-of-life | untested | no |
| nodejs | 8.15.1 | — | end-of-life | untested | no |
| nodejs | 8.16.0 | — | end-of-life | untested | no |
| nodejs | 8.16.1 | — | end-of-life | untested | no |
| nodejs | 8.16.2 | — | end-of-life | untested | no |
| nodejs | 8.17.0 | — | end-of-life | untested | no |
| nodejs | 9.0.0 | — | end-of-life | untested | no |
| nodejs | 9.1.0 | — | end-of-life | untested | no |
| nodejs | 9.2.0 | — | end-of-life | untested | no |
| nodejs | 9.2.1 | — | end-of-life | untested | no |
| nodejs | 9.3.0 | — | end-of-life | untested | no |
| nodejs | 9.4.0 | — | end-of-life | untested | no |
| nodejs | 9.5.0 | — | end-of-life | untested | no |
| nodejs | 9.6.0 | — | end-of-life | untested | no |
| nodejs | 9.6.1 | — | end-of-life | untested | no |
| nodejs | 9.7.0 | — | end-of-life | untested | no |
| nodejs | 9.7.1 | — | end-of-life | untested | no |
| nodejs | 9.8.0 | — | end-of-life | untested | no |
| nodejs | 9.9.0 | — | end-of-life | untested | no |
| nodejs | 9.10.0 | — | end-of-life | untested | no |
| nodejs | 9.10.1 | — | end-of-life | untested | no |
| nodejs | 9.11.0 | — | end-of-life | untested | no |
| nodejs | 9.11.1 | — | end-of-life | untested | no |
| nodejs | 9.11.2 | — | end-of-life | untested | no |
| nodejs | 10.0.0 | — | end-of-life | untested | no |
| nodejs | 10.1.0 | — | end-of-life | untested | no |
| nodejs | 10.2.0 | — | end-of-life | untested | no |
| nodejs | 10.2.1 | — | end-of-life | untested | no |
| nodejs | 10.3.0 | — | end-of-life | untested | no |
| nodejs | 10.4.0 | — | end-of-life | untested | no |
| nodejs | 10.4.1 | — | end-of-life | untested | no |
| nodejs | 10.5.0 | — | end-of-life | untested | no |
| nodejs | 10.6.0 | — | end-of-life | untested | no |
| nodejs | 10.7.0 | — | end-of-life | untested | no |
| nodejs | 10.8.0 | — | end-of-life | untested | no |
| nodejs | 10.9.0 | — | end-of-life | untested | no |
| nodejs | 10.10.0 | — | end-of-life | untested | no |
| nodejs | 10.11.0 | — | end-of-life | untested | no |
| nodejs | 10.12.0 | — | end-of-life | untested | no |
| nodejs | 10.13.0 | — | end-of-life | untested | no |
| nodejs | 10.14.0 | — | end-of-life | untested | no |
| nodejs | 10.14.1 | — | end-of-life | untested | no |
| nodejs | 10.14.2 | — | end-of-life | untested | no |
| nodejs | 10.15.0 | — | end-of-life | untested | no |
| nodejs | 10.15.1 | — | end-of-life | untested | no |
| nodejs | 10.15.2 | — | end-of-life | untested | no |
| nodejs | 10.15.3 | — | end-of-life | untested | no |
| nodejs | 10.16.0 | — | end-of-life | untested | no |
| nodejs | 10.16.1 | — | end-of-life | untested | no |
| nodejs | 10.16.2 | — | end-of-life | untested | no |
| nodejs | 10.16.3 | — | end-of-life | untested | no |
| nodejs | 10.17.0 | — | end-of-life | untested | no |
| nodejs | 10.18.0 | — | end-of-life | untested | no |
| nodejs | 10.18.1 | — | end-of-life | untested | no |
| nodejs | 10.19.0 | — | end-of-life | untested | no |
| nodejs | 10.20.0 | — | end-of-life | untested | no |
| nodejs | 10.20.1 | — | end-of-life | untested | no |
| nodejs | 10.21.0 | — | end-of-life | untested | no |
| nodejs | 10.22.0 | — | end-of-life | untested | no |
| nodejs | 10.22.1 | — | end-of-life | untested | no |
| nodejs | 10.23.0 | — | end-of-life | untested | no |
| nodejs | 10.23.1 | — | end-of-life | untested | no |
| nodejs | 10.23.2 | — | end-of-life | untested | no |
| nodejs | 10.23.3 | — | end-of-life | untested | no |
| nodejs | 10.24.0 | — | end-of-life | untested | no |
| nodejs | 10.24.1 | — | end-of-life | untested | no |
| nodejs | 11.0.0 | — | end-of-life | untested | no |
| nodejs | 11.1.0 | — | end-of-life | untested | no |
| nodejs | 11.2.0 | — | end-of-life | untested | no |
| nodejs | 11.3.0 | — | end-of-life | untested | no |
| nodejs | 11.4.0 | — | end-of-life | untested | no |
| nodejs | 11.5.0 | — | end-of-life | untested | no |
| nodejs | 11.6.0 | — | end-of-life | untested | no |
| nodejs | 11.7.0 | — | end-of-life | untested | no |
| nodejs | 11.8.0 | — | end-of-life | untested | no |
| nodejs | 11.9.0 | — | end-of-life | untested | no |
| nodejs | 11.10.0 | — | end-of-life | untested | no |
| nodejs | 11.10.1 | — | end-of-life | untested | no |
| nodejs | 11.11.0 | — | end-of-life | untested | no |
| nodejs | 11.12.0 | — | end-of-life | untested | no |
| nodejs | 11.13.0 | — | end-of-life | untested | no |
| nodejs | 11.14.0 | — | end-of-life | untested | no |
| nodejs | 11.15.0 | — | end-of-life | untested | no |
| nodejs | 12.0.0 | — | end-of-life | untested | no |
| nodejs | 12.1.0 | — | end-of-life | untested | no |
| nodejs | 12.2.0 | — | end-of-life | untested | no |
| nodejs | 12.3.0 | — | end-of-life | untested | no |
| nodejs | 12.3.1 | — | end-of-life | untested | no |
| nodejs | 12.4.0 | — | end-of-life | untested | no |
| nodejs | 12.5.0 | — | end-of-life | untested | no |
| nodejs | 12.6.0 | — | end-of-life | untested | no |
| nodejs | 12.7.0 | — | end-of-life | untested | no |
| nodejs | 12.8.0 | — | end-of-life | untested | no |
| nodejs | 12.8.1 | — | end-of-life | untested | no |
| nodejs | 12.9.0 | — | end-of-life | untested | no |
| nodejs | 12.9.1 | — | end-of-life | untested | no |
| nodejs | 12.10.0 | — | end-of-life | untested | no |
| nodejs | 12.11.0 | — | end-of-life | untested | no |
| nodejs | 12.11.1 | — | end-of-life | untested | no |
| nodejs | 12.12.0 | — | end-of-life | untested | no |
| nodejs | 12.13.0 | — | end-of-life | untested | no |
| nodejs | 12.13.1 | — | end-of-life | untested | no |
| nodejs | 12.14.0 | — | end-of-life | untested | no |
| nodejs | 12.14.1 | — | end-of-life | untested | no |
| nodejs | 12.15.0 | — | end-of-life | untested | no |
| nodejs | 12.16.0 | — | end-of-life | untested | no |
| nodejs | 12.16.1 | — | end-of-life | untested | no |
| nodejs | 12.16.2 | — | end-of-life | untested | no |
| nodejs | 12.16.3 | — | end-of-life | untested | no |
| nodejs | 12.17.0 | — | end-of-life | untested | no |
| nodejs | 12.18.0 | — | end-of-life | untested | no |
| nodejs | 12.18.1 | — | end-of-life | untested | no |
| nodejs | 12.18.2 | — | end-of-life | untested | no |
| nodejs | 12.18.3 | — | end-of-life | untested | no |
| nodejs | 12.18.4 | — | end-of-life | untested | no |
| nodejs | 12.19.0 | — | end-of-life | untested | no |
| nodejs | 12.19.1 | — | end-of-life | untested | no |
| nodejs | 12.20.0 | — | end-of-life | untested | no |
| nodejs | 12.20.1 | — | end-of-life | untested | no |
| nodejs | 12.20.2 | — | end-of-life | untested | no |
| nodejs | 12.21.0 | — | end-of-life | untested | no |
| nodejs | 12.22.0 | — | end-of-life | untested | no |
| nodejs | 12.22.1 | — | end-of-life | untested | no |
| nodejs | 12.22.2 | — | end-of-life | untested | no |
| nodejs | 12.22.3 | — | end-of-life | untested | no |
| nodejs | 12.22.4 | — | end-of-life | untested | no |
| nodejs | 12.22.5 | — | end-of-life | untested | no |
| nodejs | 12.22.6 | — | end-of-life | untested | no |
| nodejs | 12.22.7 | — | end-of-life | untested | no |
| nodejs | 12.22.8 | — | end-of-life | untested | no |
| nodejs | 12.22.9 | — | end-of-life | untested | no |
| nodejs | 12.22.10 | — | end-of-life | untested | no |
| nodejs | 12.22.11 | — | end-of-life | untested | no |
| nodejs | 12.22.12 | — | end-of-life | untested | no |
| nodejs | 13.0.0 | — | end-of-life | untested | no |
| nodejs | 13.0.1 | — | end-of-life | untested | no |
| nodejs | 13.1.0 | — | end-of-life | untested | no |
| nodejs | 13.2.0 | — | end-of-life | untested | no |
| nodejs | 13.3.0 | — | end-of-life | untested | no |
| nodejs | 13.4.0 | — | end-of-life | untested | no |
| nodejs | 13.5.0 | — | end-of-life | untested | no |
| nodejs | 13.6.0 | — | end-of-life | untested | no |
| nodejs | 13.7.0 | — | end-of-life | untested | no |
| nodejs | 13.8.0 | — | end-of-life | untested | no |
| nodejs | 13.9.0 | — | end-of-life | untested | no |
| nodejs | 13.10.0 | — | end-of-life | untested | no |
| nodejs | 13.10.1 | — | end-of-life | untested | no |
| nodejs | 13.11.0 | — | end-of-life | untested | no |
| nodejs | 13.12.0 | — | end-of-life | untested | no |
| nodejs | 13.13.0 | — | end-of-life | untested | no |
| nodejs | 13.14.0 | — | end-of-life | untested | no |
| nodejs | 14.0.0 | — | end-of-life | untested | no |
| nodejs | 14.1.0 | — | end-of-life | untested | no |
| nodejs | 14.2.0 | — | end-of-life | untested | no |
| nodejs | 14.3.0 | — | end-of-life | untested | no |
| nodejs | 14.4.0 | — | end-of-life | untested | no |
| nodejs | 14.5.0 | — | end-of-life | untested | no |
| nodejs | 14.6.0 | — | end-of-life | untested | no |
| nodejs | 14.7.0 | — | end-of-life | untested | no |
| nodejs | 14.8.0 | — | end-of-life | untested | no |
| nodejs | 14.9.0 | — | end-of-life | untested | no |
| nodejs | 14.10.0 | — | end-of-life | untested | no |
| nodejs | 14.10.1 | — | end-of-life | untested | no |
| nodejs | 14.11.0 | — | end-of-life | untested | no |
| nodejs | 14.12.0 | — | end-of-life | untested | no |
| nodejs | 14.13.0 | — | end-of-life | untested | no |
| nodejs | 14.13.1 | — | end-of-life | untested | no |
| nodejs | 14.14.0 | — | end-of-life | untested | no |
| nodejs | 14.15.0 | — | end-of-life | untested | no |
| nodejs | 14.15.1 | — | end-of-life | untested | no |
| nodejs | 14.15.2 | — | end-of-life | untested | no |
| nodejs | 14.15.3 | — | end-of-life | untested | no |
| nodejs | 14.15.4 | — | end-of-life | untested | no |
| nodejs | 14.15.5 | — | end-of-life | untested | no |
| nodejs | 14.16.0 | — | end-of-life | untested | no |
| nodejs | 14.16.1 | — | end-of-life | untested | no |
| nodejs | 14.17.0 | — | end-of-life | untested | no |
| nodejs | 14.17.1 | — | end-of-life | untested | no |
| nodejs | 14.17.2 | — | end-of-life | untested | no |
| nodejs | 14.17.3 | — | end-of-life | untested | no |
| nodejs | 14.17.4 | — | end-of-life | untested | no |
| nodejs | 14.17.5 | — | end-of-life | untested | no |
| nodejs | 14.17.6 | — | end-of-life | untested | no |
| nodejs | 14.18.0 | — | end-of-life | untested | no |
| nodejs | 14.18.1 | — | end-of-life | untested | no |
| nodejs | 14.18.2 | — | end-of-life | untested | no |
| nodejs | 14.18.3 | — | end-of-life | untested | no |
| nodejs | 14.19.0 | — | end-of-life | untested | no |
| nodejs | 14.19.1 | — | end-of-life | untested | no |
| nodejs | 14.19.2 | — | end-of-life | untested | no |
| nodejs | 14.19.3 | — | end-of-life | untested | no |
| nodejs | 14.20.0 | — | end-of-life | untested | no |
| nodejs | 14.20.1 | — | end-of-life | untested | no |
| nodejs | 14.21.0 | — | end-of-life | untested | no |
| nodejs | 14.21.1 | — | end-of-life | untested | no |
| nodejs | 14.21.2 | — | end-of-life | untested | no |
| nodejs | 14.21.3 | — | end-of-life | untested | no |
| nodejs | 15.0.0 | — | end-of-life | untested | no |
| nodejs | 15.0.1 | — | end-of-life | untested | no |
| nodejs | 15.1.0 | — | end-of-life | untested | no |
| nodejs | 15.2.0 | — | end-of-life | untested | no |
| nodejs | 15.2.1 | — | end-of-life | untested | no |
| nodejs | 15.3.0 | — | end-of-life | untested | no |
| nodejs | 15.4.0 | — | end-of-life | untested | no |
| nodejs | 15.5.0 | — | end-of-life | untested | no |
| nodejs | 15.5.1 | — | end-of-life | untested | no |
| nodejs | 15.6.0 | — | end-of-life | untested | no |
| nodejs | 15.7.0 | — | end-of-life | untested | no |
| nodejs | 15.8.0 | — | end-of-life | untested | no |
| nodejs | 15.9.0 | — | end-of-life | untested | no |
| nodejs | 15.10.0 | — | end-of-life | untested | no |
| nodejs | 15.11.0 | — | end-of-life | untested | no |
| nodejs | 15.12.0 | — | end-of-life | untested | no |
| nodejs | 15.13.0 | — | end-of-life | untested | no |
| nodejs | 15.14.0 | — | end-of-life | untested | no |
| nodejs | 16.0.0 | — | end-of-life | untested | no |
| nodejs | 16.1.0 | — | end-of-life | untested | no |
| nodejs | 16.2.0 | — | end-of-life | untested | no |
| nodejs | 16.3.0 | — | end-of-life | untested | no |
| nodejs | 16.4.0 | — | end-of-life | untested | no |
| nodejs | 16.4.1 | — | end-of-life | untested | no |
| nodejs | 16.4.2 | — | end-of-life | untested | no |
| nodejs | 16.5.0 | — | end-of-life | untested | no |
| nodejs | 16.6.0 | — | end-of-life | untested | no |
| nodejs | 16.6.1 | — | end-of-life | untested | no |
| nodejs | 16.6.2 | — | end-of-life | untested | no |
| nodejs | 16.7.0 | — | end-of-life | untested | no |
| nodejs | 16.8.0 | — | end-of-life | untested | no |
| nodejs | 16.9.0 | — | end-of-life | untested | no |
| nodejs | 16.9.1 | — | end-of-life | untested | no |
| nodejs | 16.10.0 | — | end-of-life | untested | no |
| nodejs | 16.11.0 | — | end-of-life | untested | no |
| nodejs | 16.11.1 | — | end-of-life | untested | no |
| nodejs | 16.12.0 | — | end-of-life | untested | no |
| nodejs | 16.13.0 | — | end-of-life | untested | no |
| nodejs | 16.13.1 | — | end-of-life | untested | no |
| nodejs | 16.13.2 | — | end-of-life | untested | no |
| nodejs | 16.14.0 | — | end-of-life | untested | no |
| nodejs | 16.14.1 | — | end-of-life | untested | no |
| nodejs | 16.14.2 | — | end-of-life | untested | no |
| nodejs | 16.15.0 | — | end-of-life | untested | no |
| nodejs | 16.15.1 | — | end-of-life | untested | no |
| nodejs | 16.16.0 | — | end-of-life | untested | no |
| nodejs | 16.17.0 | — | end-of-life | untested | no |
| nodejs | 16.17.1 | — | end-of-life | untested | no |
| nodejs | 16.18.0 | — | end-of-life | untested | no |
| nodejs | 16.18.1 | — | end-of-life | untested | no |
| nodejs | 16.19.0 | — | end-of-life | untested | no |
| nodejs | 16.19.1 | — | end-of-life | untested | no |
| nodejs | 16.20.0 | — | end-of-life | untested | no |
| nodejs | 16.20.1 | — | end-of-life | untested | no |
| nodejs | 16.20.2 | — | end-of-life | untested | no |
| nodejs | 17.0.0 | — | end-of-life | untested | no |
| nodejs | 17.0.1 | — | end-of-life | untested | no |
| nodejs | 17.1.0 | — | end-of-life | untested | no |
| nodejs | 17.2.0 | — | end-of-life | untested | no |
| nodejs | 17.3.0 | — | end-of-life | untested | no |
| nodejs | 17.3.1 | — | end-of-life | untested | no |
| nodejs | 17.4.0 | — | end-of-life | untested | no |
| nodejs | 17.5.0 | — | end-of-life | untested | no |
| nodejs | 17.6.0 | — | end-of-life | untested | no |
| nodejs | 17.7.0 | — | end-of-life | untested | no |
| nodejs | 17.7.1 | — | end-of-life | untested | no |
| nodejs | 17.7.2 | — | end-of-life | untested | no |
| nodejs | 17.8.0 | — | end-of-life | untested | no |
| nodejs | 17.9.0 | — | end-of-life | untested | no |
| nodejs | 17.9.1 | — | end-of-life | untested | no |
| nodejs | 18.0.0 | — | end-of-life | untested | no |
| nodejs | 18.1.0 | — | end-of-life | untested | no |
| nodejs | 18.2.0 | — | end-of-life | untested | no |
| nodejs | 18.3.0 | — | end-of-life | untested | no |
| nodejs | 18.4.0 | — | end-of-life | untested | no |
| nodejs | 18.5.0 | — | end-of-life | untested | no |
| nodejs | 18.6.0 | — | end-of-life | untested | no |
| nodejs | 18.7.0 | — | end-of-life | untested | no |
| nodejs | 18.8.0 | — | end-of-life | untested | no |
| nodejs | 18.9.0 | — | end-of-life | untested | no |
| nodejs | 18.9.1 | — | end-of-life | untested | no |
| nodejs | 18.10.0 | — | end-of-life | untested | no |
| nodejs | 18.11.0 | — | end-of-life | untested | no |
| nodejs | 18.12.0 | — | end-of-life | untested | no |
| nodejs | 18.12.1 | — | end-of-life | untested | no |
| nodejs | 18.13.0 | — | end-of-life | untested | no |
| nodejs | 18.14.0 | — | end-of-life | untested | no |
| nodejs | 18.14.1 | — | end-of-life | untested | no |
| nodejs | 18.14.2 | — | end-of-life | untested | no |
| nodejs | 18.15.0 | — | end-of-life | untested | no |
| nodejs | 18.16.0 | — | end-of-life | untested | no |
| nodejs | 18.16.1 | — | end-of-life | untested | no |
| nodejs | 18.17.0 | — | end-of-life | untested | no |
| nodejs | 18.17.1 | — | end-of-life | untested | no |
| nodejs | 18.18.0 | — | end-of-life | untested | no |
| nodejs | 18.18.1 | — | end-of-life | untested | no |
| nodejs | 18.18.2 | — | end-of-life | untested | no |
| nodejs | 18.19.0 | — | end-of-life | untested | no |
| nodejs | 18.19.1 | — | end-of-life | untested | no |
| nodejs | 18.20.0 | — | end-of-life | untested | no |
| nodejs | 18.20.1 | — | end-of-life | untested | no |
| nodejs | 18.20.2 | — | end-of-life | untested | no |
| nodejs | 18.20.3 | — | end-of-life | untested | no |
| nodejs | 18.20.4 | — | end-of-life | untested | no |
| nodejs | 18.20.5 | — | end-of-life | untested | no |
| nodejs | 18.20.6 | — | end-of-life | untested | no |
| nodejs | 18.20.7 | — | end-of-life | untested | no |
| nodejs | 18.20.8 | — | end-of-life | untested | no |
| nodejs | 19.0.0 | — | end-of-life | untested | no |
| nodejs | 19.0.1 | — | end-of-life | untested | no |
| nodejs | 19.1.0 | — | end-of-life | untested | no |
| nodejs | 19.2.0 | — | end-of-life | untested | no |
| nodejs | 19.3.0 | — | end-of-life | untested | no |
| nodejs | 19.4.0 | — | end-of-life | untested | no |
| nodejs | 19.5.0 | — | end-of-life | untested | no |
| nodejs | 19.6.0 | — | end-of-life | untested | no |
| nodejs | 19.6.1 | — | end-of-life | untested | no |
| nodejs | 19.7.0 | — | end-of-life | untested | no |
| nodejs | 19.8.0 | — | end-of-life | untested | no |
| nodejs | 19.8.1 | — | end-of-life | untested | no |
| nodejs | 19.9.0 | — | end-of-life | untested | no |
| nodejs | 20.0.0 | — | end-of-life | untested | no |
| nodejs | 20.1.0 | — | end-of-life | untested | no |
| nodejs | 20.2.0 | — | end-of-life | untested | no |
| nodejs | 20.3.0 | — | end-of-life | untested | no |
| nodejs | 20.3.1 | — | end-of-life | untested | no |
| nodejs | 20.4.0 | — | end-of-life | untested | no |
| nodejs | 20.5.0 | — | end-of-life | untested | no |
| nodejs | 20.5.1 | — | end-of-life | untested | no |
| nodejs | 20.6.0 | — | end-of-life | untested | no |
| nodejs | 20.6.1 | — | end-of-life | untested | no |
| nodejs | 20.7.0 | — | end-of-life | untested | no |
| nodejs | 20.8.0 | — | end-of-life | untested | no |
| nodejs | 20.8.1 | — | end-of-life | untested | no |
| nodejs | 20.9.0 | — | end-of-life | untested | no |
| nodejs | 20.10.0 | — | end-of-life | untested | no |
| nodejs | 20.11.0 | — | end-of-life | untested | no |
| nodejs | 20.11.1 | — | end-of-life | untested | no |
| nodejs | 20.12.0 | — | end-of-life | untested | no |
| nodejs | 20.12.1 | — | end-of-life | untested | no |
| nodejs | 20.12.2 | — | end-of-life | untested | no |
| nodejs | 20.13.0 | — | end-of-life | untested | no |
| nodejs | 20.13.1 | — | end-of-life | untested | no |
| nodejs | 20.14.0 | — | end-of-life | untested | no |
| nodejs | 20.15.0 | — | end-of-life | untested | no |
| nodejs | 20.15.1 | — | end-of-life | untested | no |
| nodejs | 20.16.0 | — | end-of-life | untested | no |
| nodejs | 20.17.0 | — | end-of-life | untested | no |
| nodejs | 20.18.0 | — | end-of-life | untested | no |
| nodejs | 20.18.1 | — | end-of-life | untested | no |
| nodejs | 20.18.2 | — | end-of-life | untested | no |
| nodejs | 20.18.3 | — | end-of-life | untested | no |
| nodejs | 20.19.0 | — | end-of-life | untested | no |
| nodejs | 20.19.1 | — | end-of-life | untested | no |
| nodejs | 20.19.2 | — | end-of-life | untested | no |
| nodejs | 20.19.3 | — | end-of-life | untested | no |
| nodejs | 20.19.4 | — | end-of-life | untested | no |
| nodejs | 20.19.5 | — | end-of-life | untested | no |
| nodejs | 20.19.6 | — | end-of-life | untested | no |
| nodejs | 20.20.0 | — | end-of-life | untested | no |
| nodejs | 20.20.1 | — | end-of-life | untested | no |
| nodejs | 20.20.2 | — | end-of-life | untested | no |
| nodejs | 21.0.0 | — | end-of-life | untested | no |
| nodejs | 21.1.0 | — | end-of-life | untested | no |
| nodejs | 21.2.0 | — | end-of-life | untested | no |
| nodejs | 21.3.0 | — | end-of-life | untested | no |
| nodejs | 21.4.0 | — | end-of-life | untested | no |
| nodejs | 21.5.0 | — | end-of-life | untested | no |
| nodejs | 21.6.0 | — | end-of-life | untested | no |
| nodejs | 21.6.1 | — | end-of-life | untested | no |
| nodejs | 21.6.2 | — | end-of-life | untested | no |
| nodejs | 21.7.0 | — | end-of-life | untested | no |
| nodejs | 21.7.1 | — | end-of-life | untested | no |
| nodejs | 21.7.2 | — | end-of-life | untested | no |
| nodejs | 21.7.3 | — | end-of-life | untested | no |
| nodejs | 22.0.0 | — | maintained | untested | no |
| nodejs | 22.1.0 | — | maintained | untested | no |
| nodejs | 22.2.0 | — | maintained | untested | no |
| nodejs | 22.3.0 | — | maintained | untested | no |
| nodejs | 22.4.0 | — | maintained | untested | no |
| nodejs | 22.4.1 | — | maintained | untested | no |
| nodejs | 22.5.0 | — | maintained | untested | no |
| nodejs | 22.5.1 | — | maintained | untested | no |
| nodejs | 22.6.0 | — | maintained | untested | no |
| nodejs | 22.7.0 | — | maintained | untested | no |
| nodejs | 22.8.0 | — | maintained | untested | no |
| nodejs | 22.9.0 | — | maintained | untested | no |
| nodejs | 22.10.0 | — | maintained | untested | no |
| nodejs | 22.11.0 | — | maintained | untested | no |
| nodejs | 22.12.0 | — | maintained | untested | no |
| nodejs | 22.13.0 | — | maintained | untested | no |
| nodejs | 22.13.1 | — | maintained | untested | no |
| nodejs | 22.14.0 | — | maintained | untested | no |
| nodejs | 22.15.0 | — | maintained | untested | no |
| nodejs | 22.15.1 | — | maintained | untested | no |
| nodejs | 22.16.0 | — | maintained | untested | no |
| nodejs | 22.17.0 | — | maintained | untested | no |
| nodejs | 22.17.1 | — | maintained | untested | no |
| nodejs | 22.18.0 | — | maintained | untested | no |
| nodejs | 22.19.0 | — | maintained | untested | no |
| nodejs | 22.20.0 | — | maintained | untested | no |
| nodejs | 22.21.0 | — | maintained | untested | no |
| nodejs | 22.21.1 | — | maintained | untested | no |
| nodejs | 22.22.0 | — | maintained | untested | no |
| nodejs | 22.22.1 | — | maintained | untested | no |
| nodejs | 22.22.2 | — | maintained | untested | no |
| nodejs | 22.22.3 | — | maintained | untested | no |
| nodejs | 22.23.0 | — | maintained | untested | no |
| nodejs | 22.23.1 | — | maintained | untested | no |
| nodejs | 22.23.2 | — | maintained | untested | no |
| nodejs | 22.23.3 | — | maintained | passed 2026-10-06 | yes |
| nodejs | 23.0.0 | — | end-of-life | untested | no |
| nodejs | 23.1.0 | — | end-of-life | untested | no |
| nodejs | 23.2.0 | — | end-of-life | untested | no |
| nodejs | 23.3.0 | — | end-of-life | untested | no |
| nodejs | 23.4.0 | — | end-of-life | untested | no |
| nodejs | 23.5.0 | — | end-of-life | untested | no |
| nodejs | 23.6.0 | — | end-of-life | untested | no |
| nodejs | 23.6.1 | — | end-of-life | untested | no |
| nodejs | 23.7.0 | — | end-of-life | untested | no |
| nodejs | 23.8.0 | — | end-of-life | untested | no |
| nodejs | 23.9.0 | — | end-of-life | untested | no |
| nodejs | 23.10.0 | — | end-of-life | untested | no |
| nodejs | 23.11.0 | — | end-of-life | untested | no |
| nodejs | 23.11.1 | — | end-of-life | untested | no |
| nodejs | 24.0.0 | — | maintained | untested | no |
| nodejs | 24.0.1 | — | maintained | untested | no |
| nodejs | 24.0.2 | — | maintained | untested | no |
| nodejs | 24.1.0 | — | maintained | untested | no |
| nodejs | 24.2.0 | — | maintained | untested | no |
| nodejs | 24.3.0 | — | maintained | untested | no |
| nodejs | 24.4.0 | — | maintained | untested | no |
| nodejs | 24.4.1 | — | maintained | untested | no |
| nodejs | 24.5.0 | — | maintained | untested | no |
| nodejs | 24.6.0 | — | maintained | untested | no |
| nodejs | 24.7.0 | — | maintained | untested | no |
| nodejs | 24.8.0 | — | maintained | untested | no |
| nodejs | 24.9.0 | — | maintained | untested | no |
| nodejs | 24.10.0 | — | maintained | untested | no |
| nodejs | 24.11.0 | — | maintained | untested | no |
| nodejs | 24.11.1 | — | maintained | untested | no |
| nodejs | 24.12.0 | — | maintained | untested | no |
| nodejs | 24.13.0 | — | maintained | untested | no |
| nodejs | 24.13.1 | — | maintained | untested | no |
| nodejs | 24.14.0 | — | maintained | untested | no |
| nodejs | 24.14.1 | — | maintained | untested | no |
| nodejs | 24.15.0 | — | maintained | untested | no |
| nodejs | 24.16.0 | — | maintained | untested | no |
| nodejs | 24.17.0 | — | maintained | untested | no |
| nodejs | 24.18.0 | — | maintained | untested | no |
| nodejs | 24.18.1 | — | maintained | untested | no |
| nodejs | 24.19.0 | — | maintained | untested | no |
| nodejs | 24.20.0 | — | maintained | untested | no |
| nodejs | 24.21.0 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 25.0.0 | — | end-of-life | untested | no |
| nodejs | 25.1.0 | — | end-of-life | untested | no |
| nodejs | 25.2.0 | — | end-of-life | untested | no |
| nodejs | 25.2.1 | — | end-of-life | untested | no |
| nodejs | 25.3.0 | — | end-of-life | untested | no |
| nodejs | 25.4.0 | — | end-of-life | untested | no |
| nodejs | 25.5.0 | — | end-of-life | untested | no |
| nodejs | 25.6.0 | — | end-of-life | untested | no |
| nodejs | 25.6.1 | — | end-of-life | untested | no |
| nodejs | 25.7.0 | — | end-of-life | untested | no |
| nodejs | 25.8.0 | — | end-of-life | untested | no |
| nodejs | 25.8.1 | — | end-of-life | untested | no |
| nodejs | 25.8.2 | — | end-of-life | untested | no |
| nodejs | 25.9.0 | — | end-of-life | untested | no |
| nodejs | 26.0.0 | — | maintained | untested | no |
| nodejs | 26.1.0 | — | maintained | untested | no |
| nodejs | 26.2.0 | — | maintained | untested | no |
| nodejs | 26.3.0 | — | maintained | untested | no |
| nodejs | 26.3.1 | — | maintained | untested | no |
| nodejs | 26.4.0 | — | maintained | untested | no |
| nodejs | 26.5.0 | — | maintained | untested | no |
| nodejs | 26.5.1 | — | maintained | untested | no |
| nodejs | 26.6.0 | — | maintained | untested | no |
| nodejs | 26.7.0 | — | maintained | untested | no |
| nodejs | 26.8.0 | — | maintained | untested | no |
| nodejs | 26.8.1 | — | maintained | untested | no |
| nodejs | 26.8.2 | — | maintained | untested | no |
| nodejs | 26.9.0 | — | maintained | untested | no |
| nodejs | 26.10.0 | — | maintained | passed 2026-10-06 | yes |
heroku-python
Stack: heroku-22 · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/heroku-python:clusterbuilder-heroku-python@sha256:85b08ce2c6fc5c3e6232e50d5562496afd5bf5cb190620577254e3bc57a6c008
Buildpacks: heroku/python@6.7.0, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 3.14.8.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| python | 3.11.17 | — | maintained | passed 2026-10-06 | yes |
| python | 3.12.15 | — | maintained | passed 2026-10-06 | yes |
| python | 3.13.16 | — | maintained | passed 2026-10-06 | yes |
| python | 3.14.8 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| python | 3.10.0 | — | end-of-life | untested | no |
| python | 3.10.1 | — | end-of-life | untested | no |
| python | 3.10.2 | — | end-of-life | untested | no |
| python | 3.10.3 | — | end-of-life | untested | no |
| python | 3.10.4 | — | end-of-life | untested | no |
| python | 3.10.5 | — | end-of-life | untested | no |
| python | 3.10.6 | — | end-of-life | untested | no |
| python | 3.10.7 | — | end-of-life | untested | no |
| python | 3.10.8 | — | end-of-life | untested | no |
| python | 3.10.9 | — | end-of-life | untested | no |
| python | 3.10.10 | — | end-of-life | untested | no |
| python | 3.10.11 | — | end-of-life | untested | no |
| python | 3.10.12 | — | end-of-life | untested | no |
| python | 3.10.13 | — | end-of-life | untested | no |
| python | 3.10.14 | — | end-of-life | untested | no |
| python | 3.10.15 | — | end-of-life | untested | no |
| python | 3.10.16 | — | end-of-life | untested | no |
| python | 3.10.17 | — | end-of-life | untested | no |
| python | 3.10.18 | — | end-of-life | untested | no |
| python | 3.10.19 | — | end-of-life | untested | no |
| python | 3.10.20 | — | end-of-life | untested | no |
| python | 3.10.21 | — | end-of-life | untested | no |
| python | 3.10.22 | — | end-of-life | untested | no |
| python | 3.11.0 | — | maintained | untested | no |
| python | 3.11.1 | — | maintained | untested | no |
| python | 3.11.2 | — | maintained | untested | no |
| python | 3.11.3 | — | maintained | untested | no |
| python | 3.11.4 | — | maintained | untested | no |
| python | 3.11.5 | — | maintained | untested | no |
| python | 3.11.6 | — | maintained | untested | no |
| python | 3.11.7 | — | maintained | untested | no |
| python | 3.11.8 | — | maintained | untested | no |
| python | 3.11.9 | — | maintained | untested | no |
| python | 3.11.10 | — | maintained | untested | no |
| python | 3.11.11 | — | maintained | untested | no |
| python | 3.11.12 | — | maintained | untested | no |
| python | 3.11.13 | — | maintained | untested | no |
| python | 3.11.14 | — | maintained | untested | no |
| python | 3.11.15 | — | maintained | untested | no |
| python | 3.11.16 | — | maintained | untested | no |
| python | 3.11.17 | — | maintained | passed 2026-10-06 | yes |
| python | 3.12.0 | — | maintained | untested | no |
| python | 3.12.1 | — | maintained | untested | no |
| python | 3.12.2 | — | maintained | untested | no |
| python | 3.12.3 | — | maintained | untested | no |
| python | 3.12.4 | — | maintained | untested | no |
| python | 3.12.5 | — | maintained | untested | no |
| python | 3.12.6 | — | maintained | untested | no |
| python | 3.12.7 | — | maintained | untested | no |
| python | 3.12.8 | — | maintained | untested | no |
| python | 3.12.9 | — | maintained | untested | no |
| python | 3.12.10 | — | maintained | untested | no |
| python | 3.12.11 | — | maintained | untested | no |
| python | 3.12.12 | — | maintained | untested | no |
| python | 3.12.13 | — | maintained | untested | no |
| python | 3.12.14 | — | maintained | untested | no |
| python | 3.12.15 | — | maintained | passed 2026-10-06 | yes |
| python | 3.13.0 | — | maintained | untested | no |
| python | 3.13.1 | — | maintained | untested | no |
| python | 3.13.2 | — | maintained | untested | no |
| python | 3.13.3 | — | maintained | untested | no |
| python | 3.13.4 | — | maintained | untested | no |
| python | 3.13.5 | — | maintained | untested | no |
| python | 3.13.6 | — | maintained | untested | no |
| python | 3.13.7 | — | maintained | untested | no |
| python | 3.13.8 | — | maintained | untested | no |
| python | 3.13.9 | — | maintained | untested | no |
| python | 3.13.10 | — | maintained | untested | no |
| python | 3.13.11 | — | maintained | untested | no |
| python | 3.13.12 | — | maintained | untested | no |
| python | 3.13.13 | — | maintained | untested | no |
| python | 3.13.14 | — | maintained | untested | no |
| python | 3.13.15 | — | maintained | untested | no |
| python | 3.13.16 | — | maintained | passed 2026-10-06 | yes |
| python | 3.14.0 | — | maintained | untested | no |
| python | 3.14.1 | — | maintained | untested | no |
| python | 3.14.2 | — | maintained | untested | no |
| python | 3.14.3 | — | maintained | untested | no |
| python | 3.14.4 | — | maintained | untested | no |
| python | 3.14.5 | — | maintained | untested | no |
| python | 3.14.6 | — | maintained | untested | no |
| python | 3.14.7 | — | maintained | untested | no |
| python | 3.14.8 (default) | — | maintained | passed 2026-10-06 | yes |
heroku-ruby
Stack: heroku-22 · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/heroku-ruby:clusterbuilder-heroku-ruby@sha256:765c028d6eb0aa563706b7ec23f968ba03b95f56b15c10c832c4e5fa83175d25
Buildpacks: heroku/ruby@12.4.2, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 3.3.9.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| ruby | 3.3.9 (default) | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.3.12 | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.4.11 | — | maintained | passed 2026-10-06 | yes |
| ruby | 4.0.7 | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| ruby | 3.1.0 | — | end-of-life | untested | no |
| ruby | 3.1.1 | — | end-of-life | untested | no |
| ruby | 3.1.2 | — | end-of-life | untested | no |
| ruby | 3.1.3 | — | end-of-life | untested | no |
| ruby | 3.1.4 | — | end-of-life | untested | no |
| ruby | 3.1.5 | — | end-of-life | untested | no |
| ruby | 3.1.6 | — | end-of-life | untested | no |
| ruby | 3.1.7 | — | end-of-life | untested | no |
| ruby | 3.2.0 | — | end-of-life | untested | no |
| ruby | 3.2.1 | — | end-of-life | untested | no |
| ruby | 3.2.2 | — | end-of-life | untested | no |
| ruby | 3.2.3 | — | end-of-life | untested | no |
| ruby | 3.2.4 | — | end-of-life | untested | no |
| ruby | 3.2.5 | — | end-of-life | untested | no |
| ruby | 3.2.6 | — | end-of-life | untested | no |
| ruby | 3.2.7 | — | end-of-life | untested | no |
| ruby | 3.2.8 | — | end-of-life | untested | no |
| ruby | 3.2.9 | — | end-of-life | untested | no |
| ruby | 3.2.10 | — | end-of-life | untested | no |
| ruby | 3.2.11 | — | end-of-life | untested | no |
| ruby | 3.3.0 | — | maintained | untested | no |
| ruby | 3.3.1 | — | maintained | untested | no |
| ruby | 3.3.2 | — | maintained | untested | no |
| ruby | 3.3.3 | — | maintained | untested | no |
| ruby | 3.3.4 | — | maintained | untested | no |
| ruby | 3.3.5 | — | maintained | untested | no |
| ruby | 3.3.6 | — | maintained | untested | no |
| ruby | 3.3.7 | — | maintained | untested | no |
| ruby | 3.3.8 | — | maintained | untested | no |
| ruby | 3.3.9 (default) | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.3.10 | — | maintained | untested | no |
| ruby | 3.3.11 | — | maintained | untested | no |
| ruby | 3.3.12 | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.4.0 | — | maintained | untested | no |
| ruby | 3.4.1 | — | maintained | untested | no |
| ruby | 3.4.2 | — | maintained | untested | no |
| ruby | 3.4.3 | — | maintained | untested | no |
| ruby | 3.4.4 | — | maintained | untested | no |
| ruby | 3.4.5 | — | maintained | untested | no |
| ruby | 3.4.6 | — | maintained | untested | no |
| ruby | 3.4.7 | — | maintained | untested | no |
| ruby | 3.4.8 | — | maintained | untested | no |
| ruby | 3.4.9 | — | maintained | untested | no |
| ruby | 3.4.10 | — | maintained | untested | no |
| ruby | 3.4.11 | — | maintained | passed 2026-10-06 | yes |
| ruby | 4.0.0 | — | maintained | untested | no |
| ruby | 4.0.1 | — | maintained | untested | no |
| ruby | 4.0.2 | — | maintained | untested | no |
| ruby | 4.0.3 | — | maintained | untested | no |
| ruby | 4.0.4 | — | maintained | untested | no |
| ruby | 4.0.5 | — | maintained | untested | no |
| ruby | 4.0.6 | — | maintained | untested | no |
| ruby | 4.0.7 | — | maintained | passed 2026-10-06 | yes |
Availability could not be confirmed for 93 additional upstream releases (download server returned 403); these are retained as unconfirmed in the JSON catalog.
paketo-rust
Stack: heroku-22 · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/paketo-rust:clusterbuilder-paketo-rust@sha256:f540418107539a8492432f3ec8bf9c55ea2a21123b639d76ebb43b789b723dfd
Buildpacks: paketo-community/rustup@1.17.0, paketo-community/rust@0.90.0, paketo-community/rust-dist@1.50.0, paketo-community/cargo@0.16.0, paketo-buildpacks/syft@2.41.0, paketo-buildpacks/procfile@5.13.7, d2o/manifest@0.1.0.
Default selection: stable.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| rust | 1.99.0 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| rust | 1.19.0 | — | end-of-life | untested | no |
| rust | 1.20.0 | — | end-of-life | untested | no |
| rust | 1.21.0 | — | end-of-life | untested | no |
| rust | 1.22.0 | — | end-of-life | untested | no |
| rust | 1.22.1 | — | end-of-life | untested | no |
| rust | 1.23.0 | — | end-of-life | untested | no |
| rust | 1.24.0 | — | end-of-life | untested | no |
| rust | 1.24.1 | — | end-of-life | untested | no |
| rust | 1.25.0 | — | end-of-life | untested | no |
| rust | 1.26.0 | — | end-of-life | untested | no |
| rust | 1.26.1 | — | end-of-life | untested | no |
| rust | 1.26.2 | — | end-of-life | untested | no |
| rust | 1.27.0 | — | end-of-life | untested | no |
| rust | 1.27.1 | — | end-of-life | untested | no |
| rust | 1.27.2 | — | end-of-life | untested | no |
| rust | 1.28.0 | — | end-of-life | untested | no |
| rust | 1.29.0 | — | end-of-life | untested | no |
| rust | 1.29.1 | — | end-of-life | untested | no |
| rust | 1.29.2 | — | end-of-life | untested | no |
| rust | 1.30.0 | — | end-of-life | untested | no |
| rust | 1.30.1 | — | end-of-life | untested | no |
| rust | 1.31.0 | — | end-of-life | untested | no |
| rust | 1.31.1 | — | end-of-life | untested | no |
| rust | 1.32.0 | — | end-of-life | untested | no |
| rust | 1.33.0 | — | end-of-life | untested | no |
| rust | 1.34.0 | — | end-of-life | untested | no |
| rust | 1.34.1 | — | end-of-life | untested | no |
| rust | 1.34.2 | — | end-of-life | untested | no |
| rust | 1.35.0 | — | end-of-life | untested | no |
| rust | 1.36.0 | — | end-of-life | untested | no |
| rust | 1.37.0 | — | end-of-life | untested | no |
| rust | 1.38.0 | — | end-of-life | untested | no |
| rust | 1.39.0 | — | end-of-life | untested | no |
| rust | 1.40.0 | — | end-of-life | untested | no |
| rust | 1.41.0 | — | end-of-life | untested | no |
| rust | 1.41.1 | — | end-of-life | untested | no |
| rust | 1.42.0 | — | end-of-life | untested | no |
| rust | 1.43.0 | — | end-of-life | untested | no |
| rust | 1.43.1 | — | end-of-life | untested | no |
| rust | 1.44.0 | — | end-of-life | untested | no |
| rust | 1.44.1 | — | end-of-life | untested | no |
| rust | 1.45.0 | — | end-of-life | untested | no |
| rust | 1.45.1 | — | end-of-life | untested | no |
| rust | 1.45.2 | — | end-of-life | untested | no |
| rust | 1.46.0 | — | end-of-life | untested | no |
| rust | 1.47.0 | — | end-of-life | untested | no |
| rust | 1.48.0 | — | end-of-life | untested | no |
| rust | 1.49.0 | — | end-of-life | untested | no |
| rust | 1.50.0 | — | end-of-life | untested | no |
| rust | 1.51.0 | — | end-of-life | untested | no |
| rust | 1.52.0 | — | end-of-life | untested | no |
| rust | 1.52.1 | — | end-of-life | untested | no |
| rust | 1.53.0 | — | end-of-life | untested | no |
| rust | 1.54.0 | — | end-of-life | untested | no |
| rust | 1.55.0 | — | end-of-life | untested | no |
| rust | 1.56.0 | — | end-of-life | untested | no |
| rust | 1.56.1 | — | end-of-life | untested | no |
| rust | 1.57.0 | — | end-of-life | untested | no |
| rust | 1.58.0 | — | end-of-life | untested | no |
| rust | 1.58.1 | — | end-of-life | untested | no |
| rust | 1.59.0 | — | end-of-life | untested | no |
| rust | 1.60.0 | — | end-of-life | untested | no |
| rust | 1.61.0 | — | end-of-life | untested | no |
| rust | 1.62.0 | — | end-of-life | untested | no |
| rust | 1.62.1 | — | end-of-life | untested | no |
| rust | 1.63.0 | — | end-of-life | untested | no |
| rust | 1.64.0 | — | end-of-life | untested | no |
| rust | 1.65.0 | — | end-of-life | untested | no |
| rust | 1.66.0 | — | end-of-life | untested | no |
| rust | 1.66.1 | — | end-of-life | untested | no |
| rust | 1.67.0 | — | end-of-life | untested | no |
| rust | 1.67.1 | — | end-of-life | untested | no |
| rust | 1.68.0 | — | end-of-life | untested | no |
| rust | 1.68.1 | — | end-of-life | untested | no |
| rust | 1.68.2 | — | end-of-life | untested | no |
| rust | 1.69.0 | — | end-of-life | untested | no |
| rust | 1.70.0 | — | end-of-life | untested | no |
| rust | 1.71.0 | — | end-of-life | untested | no |
| rust | 1.71.1 | — | end-of-life | untested | no |
| rust | 1.72.0 | — | end-of-life | untested | no |
| rust | 1.72.1 | — | end-of-life | untested | no |
| rust | 1.73.0 | — | end-of-life | untested | no |
| rust | 1.74.0 | — | end-of-life | untested | no |
| rust | 1.74.1 | — | end-of-life | untested | no |
| rust | 1.75.0 | — | end-of-life | untested | no |
| rust | 1.76.0 | — | end-of-life | untested | no |
| rust | 1.77.0 | — | end-of-life | untested | no |
| rust | 1.77.1 | — | end-of-life | untested | no |
| rust | 1.77.2 | — | end-of-life | untested | no |
| rust | 1.78.0 | — | end-of-life | untested | no |
| rust | 1.79.0 | — | end-of-life | untested | no |
| rust | 1.80.0 | — | end-of-life | untested | no |
| rust | 1.80.1 | — | end-of-life | untested | no |
| rust | 1.81.0 | — | end-of-life | untested | no |
| rust | 1.82.0 | — | end-of-life | untested | no |
| rust | 1.83.0 | — | end-of-life | untested | no |
| rust | 1.84.0 | — | end-of-life | untested | no |
| rust | 1.84.1 | — | end-of-life | untested | no |
| rust | 1.85.0 | — | end-of-life | untested | no |
| rust | 1.85.1 | — | end-of-life | untested | no |
| rust | 1.86.0 | — | end-of-life | untested | no |
| rust | 1.87.0 | — | end-of-life | untested | no |
| rust | 1.88.0 | — | end-of-life | untested | no |
| rust | 1.89.0 | — | end-of-life | untested | no |
| rust | 1.90.0 | — | end-of-life | untested | no |
| rust | 1.91.0 | — | end-of-life | untested | no |
| rust | 1.91.1 | — | end-of-life | untested | no |
| rust | 1.92.0 | — | end-of-life | untested | no |
| rust | 1.93.0 | — | end-of-life | untested | no |
| rust | 1.93.1 | — | end-of-life | untested | no |
| rust | 1.94.0 | — | end-of-life | untested | no |
| rust | 1.94.1 | — | end-of-life | untested | no |
| rust | 1.95.0 | — | end-of-life | untested | no |
| rust | 1.96.0 | — | end-of-life | untested | no |
| rust | 1.96.1 | — | end-of-life | untested | no |
| rust | 1.97.0 | — | end-of-life | untested | no |
| rust | 1.97.1 | — | end-of-life | untested | no |
| rust | 1.98.0 | — | end-of-life | untested | no |
| rust | 1.98.1 | — | end-of-life | untested | no |
| rust | 1.99.0 (default) | — | maintained | passed 2026-10-06 | yes |
paketo-web-servers
Stack: io.buildpacks.stacks.jammy · architecture: amd64.
Builder: registry.prod.d2o.io/library/kpack/paketo-web-servers:clusterbuilder-paketo-web-servers@sha256:f2cb55455aa1c358a120dafa6b1865568ba5968aa2d5fe9dde249e6ffb531d63
Buildpacks: paketo-buildpacks/npm-install@2.3.30, paketo-buildpacks/yarn-install@2.7.25, paketo-buildpacks/node-engine@8.8.1, paketo-buildpacks/nginx@1.3.0, paketo-buildpacks/httpd@1.1.6, paketo-buildpacks/yarn@2.5.0, paketo-buildpacks/ca-certificates@3.14.1, paketo-buildpacks/watchexec@3.11.1, paketo-buildpacks/node-run-script@2.3.55, paketo-buildpacks/procfile@5.15.2, paketo-buildpacks/image-labels@4.14.2, paketo-buildpacks/environment-variables@4.12.0, paketo-buildpacks/source-removal@1.0.43, paketo-buildpacks/web-servers@4.14.1, d2o/manifest@0.1.0.
Default selection: nginx; public/; no frontend build unless configured.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nginx | 1.30.5 | — | maintained | passed 2026-10-06 | yes |
| nginx | 1.31.6 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 22.23.3 | 10.9.9 | maintained | passed 2026-10-06 | yes |
| nodejs | 24.21.0 (default) | 11.19.0 | maintained | passed 2026-10-06 | yes |
| nodejs | 26.10.0 | 11.19.1 | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| berry | 4.18.0 | — | unknown | untested | no |
| berry | 4.18.1 | — | unknown | untested | no |
| nginx | 1.30.4 | — | maintained | untested | no |
| nginx | 1.30.5 | — | maintained | passed 2026-10-06 | yes |
| nginx | 1.31.5 | — | maintained | untested | no |
| nginx | 1.31.6 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 18.20.7 | 10.8.2 | end-of-life | untested | no |
| nodejs | 18.20.8 | 10.8.2 | end-of-life | untested | no |
| nodejs | 20.20.1 | 10.8.2 | end-of-life | untested | no |
| nodejs | 20.20.2 | 10.8.2 | end-of-life | untested | no |
| nodejs | 22.23.2 | 10.9.8 | maintained | untested | no |
| nodejs | 22.23.3 | 10.9.9 | maintained | passed 2026-10-06 | yes |
| nodejs | 24.20.0 | 11.19.0 | maintained | untested | no |
| nodejs | 24.21.0 (default) | 11.19.0 | maintained | passed 2026-10-06 | yes |
| nodejs | 26.9.0 | 11.19.1 | maintained | untested | no |
| nodejs | 26.10.0 | 11.19.1 | maintained | passed 2026-10-06 | yes |
| yarn | 1.22.19 | — | unknown | untested | no |
| yarn | 1.22.22 (default) | — | unknown | passed 2026-10-06 | no |
Staging
heroku-go
Stack: heroku-22 · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/heroku-go:clusterbuilder-heroku-go@sha256:ceba7250d645a67685e9106f0ced3da994138c4cc48760cf436a9ec1d95c3d53
Buildpacks: heroku/go@4.1.1, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: latest inventory release when go.mod omits go; otherwise its requirement.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| go | 1.26.8 | — | maintained | passed 2026-10-06 | yes |
| go | 1.27.1 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| go | 1.11 | — | end-of-life | untested | no |
| go | 1.11.1 | — | end-of-life | untested | no |
| go | 1.11.2 | — | end-of-life | untested | no |
| go | 1.11.3 | — | end-of-life | untested | no |
| go | 1.11.4 | — | end-of-life | untested | no |
| go | 1.11.5 | — | end-of-life | untested | no |
| go | 1.11.6 | — | end-of-life | untested | no |
| go | 1.11.7 | — | end-of-life | untested | no |
| go | 1.11.8 | — | end-of-life | untested | no |
| go | 1.11.9 | — | end-of-life | untested | no |
| go | 1.11.10 | — | end-of-life | untested | no |
| go | 1.11.11 | — | end-of-life | untested | no |
| go | 1.11.12 | — | end-of-life | untested | no |
| go | 1.11.13 | — | end-of-life | untested | no |
| go | 1.12 | — | end-of-life | untested | no |
| go | 1.12.1 | — | end-of-life | untested | no |
| go | 1.12.2 | — | end-of-life | untested | no |
| go | 1.12.3 | — | end-of-life | untested | no |
| go | 1.12.4 | — | end-of-life | untested | no |
| go | 1.12.5 | — | end-of-life | untested | no |
| go | 1.12.6 | — | end-of-life | untested | no |
| go | 1.12.7 | — | end-of-life | untested | no |
| go | 1.12.8 | — | end-of-life | untested | no |
| go | 1.12.9 | — | end-of-life | untested | no |
| go | 1.12.10 | — | end-of-life | untested | no |
| go | 1.12.11 | — | end-of-life | untested | no |
| go | 1.12.12 | — | end-of-life | untested | no |
| go | 1.12.13 | — | end-of-life | untested | no |
| go | 1.12.14 | — | end-of-life | untested | no |
| go | 1.12.15 | — | end-of-life | untested | no |
| go | 1.12.16 | — | end-of-life | untested | no |
| go | 1.12.17 | — | end-of-life | untested | no |
| go | 1.13 | — | end-of-life | untested | no |
| go | 1.13.1 | — | end-of-life | untested | no |
| go | 1.13.2 | — | end-of-life | untested | no |
| go | 1.13.3 | — | end-of-life | untested | no |
| go | 1.13.4 | — | end-of-life | untested | no |
| go | 1.13.5 | — | end-of-life | untested | no |
| go | 1.13.6 | — | end-of-life | untested | no |
| go | 1.13.7 | — | end-of-life | untested | no |
| go | 1.13.8 | — | end-of-life | untested | no |
| go | 1.13.9 | — | end-of-life | untested | no |
| go | 1.13.10 | — | end-of-life | untested | no |
| go | 1.13.11 | — | end-of-life | untested | no |
| go | 1.13.12 | — | end-of-life | untested | no |
| go | 1.13.13 | — | end-of-life | untested | no |
| go | 1.13.14 | — | end-of-life | untested | no |
| go | 1.13.15 | — | end-of-life | untested | no |
| go | 1.14 | — | end-of-life | untested | no |
| go | 1.14.1 | — | end-of-life | untested | no |
| go | 1.14.2 | — | end-of-life | untested | no |
| go | 1.14.3 | — | end-of-life | untested | no |
| go | 1.14.4 | — | end-of-life | untested | no |
| go | 1.14.5 | — | end-of-life | untested | no |
| go | 1.14.6 | — | end-of-life | untested | no |
| go | 1.14.7 | — | end-of-life | untested | no |
| go | 1.14.8 | — | end-of-life | untested | no |
| go | 1.14.9 | — | end-of-life | untested | no |
| go | 1.14.10 | — | end-of-life | untested | no |
| go | 1.14.11 | — | end-of-life | untested | no |
| go | 1.14.12 | — | end-of-life | untested | no |
| go | 1.14.13 | — | end-of-life | untested | no |
| go | 1.14.14 | — | end-of-life | untested | no |
| go | 1.14.15 | — | end-of-life | untested | no |
| go | 1.15 | — | end-of-life | untested | no |
| go | 1.15.1 | — | end-of-life | untested | no |
| go | 1.15.2 | — | end-of-life | untested | no |
| go | 1.15.3 | — | end-of-life | untested | no |
| go | 1.15.4 | — | end-of-life | untested | no |
| go | 1.15.5 | — | end-of-life | untested | no |
| go | 1.15.6 | — | end-of-life | untested | no |
| go | 1.15.7 | — | end-of-life | untested | no |
| go | 1.15.8 | — | end-of-life | untested | no |
| go | 1.15.9 | — | end-of-life | untested | no |
| go | 1.15.10 | — | end-of-life | untested | no |
| go | 1.15.11 | — | end-of-life | untested | no |
| go | 1.15.12 | — | end-of-life | untested | no |
| go | 1.15.13 | — | end-of-life | untested | no |
| go | 1.15.14 | — | end-of-life | untested | no |
| go | 1.15.15 | — | end-of-life | untested | no |
| go | 1.16 | — | end-of-life | untested | no |
| go | 1.16.1 | — | end-of-life | untested | no |
| go | 1.16.2 | — | end-of-life | untested | no |
| go | 1.16.3 | — | end-of-life | untested | no |
| go | 1.16.4 | — | end-of-life | untested | no |
| go | 1.16.5 | — | end-of-life | untested | no |
| go | 1.16.6 | — | end-of-life | untested | no |
| go | 1.16.7 | — | end-of-life | untested | no |
| go | 1.16.8 | — | end-of-life | untested | no |
| go | 1.16.9 | — | end-of-life | untested | no |
| go | 1.16.10 | — | end-of-life | untested | no |
| go | 1.16.11 | — | end-of-life | untested | no |
| go | 1.16.12 | — | end-of-life | untested | no |
| go | 1.16.13 | — | end-of-life | untested | no |
| go | 1.16.14 | — | end-of-life | untested | no |
| go | 1.16.15 | — | end-of-life | untested | no |
| go | 1.17 | — | end-of-life | untested | no |
| go | 1.17.1 | — | end-of-life | untested | no |
| go | 1.17.2 | — | end-of-life | untested | no |
| go | 1.17.3 | — | end-of-life | untested | no |
| go | 1.17.4 | — | end-of-life | untested | no |
| go | 1.17.5 | — | end-of-life | untested | no |
| go | 1.17.6 | — | end-of-life | untested | no |
| go | 1.17.7 | — | end-of-life | untested | no |
| go | 1.17.8 | — | end-of-life | untested | no |
| go | 1.17.9 | — | end-of-life | untested | no |
| go | 1.17.10 | — | end-of-life | untested | no |
| go | 1.17.11 | — | end-of-life | untested | no |
| go | 1.17.12 | — | end-of-life | untested | no |
| go | 1.17.13 | — | end-of-life | untested | no |
| go | 1.18 | — | end-of-life | untested | no |
| go | 1.18.1 | — | end-of-life | untested | no |
| go | 1.18.2 | — | end-of-life | untested | no |
| go | 1.18.3 | — | end-of-life | untested | no |
| go | 1.18.4 | — | end-of-life | untested | no |
| go | 1.18.5 | — | end-of-life | untested | no |
| go | 1.18.6 | — | end-of-life | untested | no |
| go | 1.18.7 | — | end-of-life | untested | no |
| go | 1.18.8 | — | end-of-life | untested | no |
| go | 1.18.9 | — | end-of-life | untested | no |
| go | 1.18.10 | — | end-of-life | untested | no |
| go | 1.19 | — | end-of-life | untested | no |
| go | 1.19.1 | — | end-of-life | untested | no |
| go | 1.19.2 | — | end-of-life | untested | no |
| go | 1.19.3 | — | end-of-life | untested | no |
| go | 1.19.4 | — | end-of-life | untested | no |
| go | 1.19.5 | — | end-of-life | untested | no |
| go | 1.19.6 | — | end-of-life | untested | no |
| go | 1.19.7 | — | end-of-life | untested | no |
| go | 1.19.8 | — | end-of-life | untested | no |
| go | 1.19.9 | — | end-of-life | untested | no |
| go | 1.19.10 | — | end-of-life | untested | no |
| go | 1.19.11 | — | end-of-life | untested | no |
| go | 1.19.12 | — | end-of-life | untested | no |
| go | 1.19.13 | — | end-of-life | untested | no |
| go | 1.20 | — | end-of-life | untested | no |
| go | 1.20.1 | — | end-of-life | untested | no |
| go | 1.20.2 | — | end-of-life | untested | no |
| go | 1.20.3 | — | end-of-life | untested | no |
| go | 1.20.4 | — | end-of-life | untested | no |
| go | 1.20.5 | — | end-of-life | untested | no |
| go | 1.20.6 | — | end-of-life | untested | no |
| go | 1.20.7 | — | end-of-life | untested | no |
| go | 1.20.8 | — | end-of-life | untested | no |
| go | 1.20.9 | — | end-of-life | untested | no |
| go | 1.20.10 | — | end-of-life | untested | no |
| go | 1.20.11 | — | end-of-life | untested | no |
| go | 1.20.12 | — | end-of-life | untested | no |
| go | 1.20.13 | — | end-of-life | untested | no |
| go | 1.20.14 | — | end-of-life | untested | no |
| go | 1.21.0 | — | end-of-life | untested | no |
| go | 1.21.1 | — | end-of-life | untested | no |
| go | 1.21.2 | — | end-of-life | untested | no |
| go | 1.21.3 | — | end-of-life | untested | no |
| go | 1.21.4 | — | end-of-life | untested | no |
| go | 1.21.5 | — | end-of-life | untested | no |
| go | 1.21.6 | — | end-of-life | untested | no |
| go | 1.21.7 | — | end-of-life | untested | no |
| go | 1.21.8 | — | end-of-life | untested | no |
| go | 1.21.9 | — | end-of-life | untested | no |
| go | 1.21.10 | — | end-of-life | untested | no |
| go | 1.21.11 | — | end-of-life | untested | no |
| go | 1.21.12 | — | end-of-life | untested | no |
| go | 1.21.13 | — | end-of-life | untested | no |
| go | 1.22.0 | — | end-of-life | untested | no |
| go | 1.22.1 | — | end-of-life | untested | no |
| go | 1.22.2 | — | end-of-life | untested | no |
| go | 1.22.3 | — | end-of-life | untested | no |
| go | 1.22.4 | — | end-of-life | untested | no |
| go | 1.22.5 | — | end-of-life | untested | no |
| go | 1.22.6 | — | end-of-life | untested | no |
| go | 1.22.7 | — | end-of-life | untested | no |
| go | 1.22.8 | — | end-of-life | untested | no |
| go | 1.22.9 | — | end-of-life | untested | no |
| go | 1.22.10 | — | end-of-life | untested | no |
| go | 1.22.11 | — | end-of-life | untested | no |
| go | 1.22.12 | — | end-of-life | untested | no |
| go | 1.23.0 | — | end-of-life | untested | no |
| go | 1.23.1 | — | end-of-life | untested | no |
| go | 1.23.2 | — | end-of-life | untested | no |
| go | 1.23.3 | — | end-of-life | untested | no |
| go | 1.23.4 | — | end-of-life | untested | no |
| go | 1.23.5 | — | end-of-life | untested | no |
| go | 1.23.6 | — | end-of-life | untested | no |
| go | 1.23.7 | — | end-of-life | untested | no |
| go | 1.23.8 | — | end-of-life | untested | no |
| go | 1.23.9 | — | end-of-life | untested | no |
| go | 1.23.10 | — | end-of-life | untested | no |
| go | 1.23.11 | — | end-of-life | untested | no |
| go | 1.23.12 | — | end-of-life | untested | no |
| go | 1.24.0 | — | end-of-life | untested | no |
| go | 1.24.1 | — | end-of-life | untested | no |
| go | 1.24.2 | — | end-of-life | untested | no |
| go | 1.24.3 | — | end-of-life | untested | no |
| go | 1.24.4 | — | end-of-life | untested | no |
| go | 1.24.5 | — | end-of-life | untested | no |
| go | 1.24.6 | — | end-of-life | untested | no |
| go | 1.24.7 | — | end-of-life | untested | no |
| go | 1.24.8 | — | end-of-life | untested | no |
| go | 1.24.9 | — | end-of-life | untested | no |
| go | 1.24.10 | — | end-of-life | untested | no |
| go | 1.24.11 | — | end-of-life | untested | no |
| go | 1.24.12 | — | end-of-life | untested | no |
| go | 1.24.13 | — | end-of-life | untested | no |
| go | 1.25.0 | — | end-of-life | untested | no |
| go | 1.25.1 | — | end-of-life | untested | no |
| go | 1.25.2 | — | end-of-life | untested | no |
| go | 1.25.3 | — | end-of-life | untested | no |
| go | 1.25.4 | — | end-of-life | untested | no |
| go | 1.25.5 | — | end-of-life | untested | no |
| go | 1.25.6 | — | end-of-life | untested | no |
| go | 1.25.7 | — | end-of-life | untested | no |
| go | 1.25.8 | — | end-of-life | untested | no |
| go | 1.25.9 | — | end-of-life | untested | no |
| go | 1.25.10 | — | end-of-life | untested | no |
| go | 1.25.11 | — | end-of-life | untested | no |
| go | 1.25.12 | — | end-of-life | untested | no |
| go | 1.25.13 | — | end-of-life | untested | no |
| go | 1.25.14 | — | end-of-life | untested | no |
| go | 1.26.0 | — | maintained | untested | no |
| go | 1.26.1 | — | maintained | untested | no |
| go | 1.26.2 | — | maintained | untested | no |
| go | 1.26.3 | — | maintained | untested | no |
| go | 1.26.4 | — | maintained | untested | no |
| go | 1.26.5 | — | maintained | untested | no |
| go | 1.26.6 | — | maintained | untested | no |
| go | 1.26.7 | — | maintained | untested | no |
| go | 1.26.8 | — | maintained | passed 2026-10-06 | yes |
| go | 1.27.0 | — | maintained | untested | no |
| go | 1.27.1 (default) | — | maintained | passed 2026-10-06 | yes |
heroku-nodejs
Stack: heroku-22 · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/heroku-nodejs:clusterbuilder-heroku-nodejs@sha256:113927c52a9656be6ac949fbb7b730dc84ee2e372e3d3f6a1d3e03c78b8589c1
Buildpacks: heroku/nodejs@5.8.1, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 24.x.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nodejs | 22.23.3 | — | maintained | passed 2026-10-06 | yes |
| nodejs | 24.21.0 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 26.10.0 | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nodejs | 0.8.6 | — | unknown | untested | no |
| nodejs | 0.8.7 | — | unknown | untested | no |
| nodejs | 0.8.8 | — | unknown | untested | no |
| nodejs | 0.8.9 | — | unknown | untested | no |
| nodejs | 0.8.10 | — | unknown | untested | no |
| nodejs | 0.8.11 | — | unknown | untested | no |
| nodejs | 0.8.12 | — | unknown | untested | no |
| nodejs | 0.8.13 | — | unknown | untested | no |
| nodejs | 0.8.14 | — | unknown | untested | no |
| nodejs | 0.8.15 | — | unknown | untested | no |
| nodejs | 0.8.16 | — | unknown | untested | no |
| nodejs | 0.8.17 | — | unknown | untested | no |
| nodejs | 0.8.18 | — | unknown | untested | no |
| nodejs | 0.8.19 | — | unknown | untested | no |
| nodejs | 0.8.20 | — | unknown | untested | no |
| nodejs | 0.8.21 | — | unknown | untested | no |
| nodejs | 0.8.22 | — | unknown | untested | no |
| nodejs | 0.8.23 | — | unknown | untested | no |
| nodejs | 0.8.24 | — | unknown | untested | no |
| nodejs | 0.8.25 | — | unknown | untested | no |
| nodejs | 0.8.26 | — | unknown | untested | no |
| nodejs | 0.8.27 | — | unknown | untested | no |
| nodejs | 0.8.28 | — | unknown | untested | no |
| nodejs | 0.9.1 | — | unknown | untested | no |
| nodejs | 0.9.2 | — | unknown | untested | no |
| nodejs | 0.9.3 | — | unknown | untested | no |
| nodejs | 0.9.4 | — | unknown | untested | no |
| nodejs | 0.9.5 | — | unknown | untested | no |
| nodejs | 0.9.6 | — | unknown | untested | no |
| nodejs | 0.9.7 | — | unknown | untested | no |
| nodejs | 0.9.8 | — | unknown | untested | no |
| nodejs | 0.9.9 | — | unknown | untested | no |
| nodejs | 0.9.10 | — | unknown | untested | no |
| nodejs | 0.9.11 | — | unknown | untested | no |
| nodejs | 0.9.12 | — | unknown | untested | no |
| nodejs | 0.10.0 | — | unknown | untested | no |
| nodejs | 0.10.1 | — | unknown | untested | no |
| nodejs | 0.10.2 | — | unknown | untested | no |
| nodejs | 0.10.3 | — | unknown | untested | no |
| nodejs | 0.10.4 | — | unknown | untested | no |
| nodejs | 0.10.5 | — | unknown | untested | no |
| nodejs | 0.10.6 | — | unknown | untested | no |
| nodejs | 0.10.7 | — | unknown | untested | no |
| nodejs | 0.10.8 | — | unknown | untested | no |
| nodejs | 0.10.9 | — | unknown | untested | no |
| nodejs | 0.10.10 | — | unknown | untested | no |
| nodejs | 0.10.11 | — | unknown | untested | no |
| nodejs | 0.10.12 | — | unknown | untested | no |
| nodejs | 0.10.13 | — | unknown | untested | no |
| nodejs | 0.10.14 | — | unknown | untested | no |
| nodejs | 0.10.15 | — | unknown | untested | no |
| nodejs | 0.10.16 | — | unknown | untested | no |
| nodejs | 0.10.17 | — | unknown | untested | no |
| nodejs | 0.10.18 | — | unknown | untested | no |
| nodejs | 0.10.19 | — | unknown | untested | no |
| nodejs | 0.10.20 | — | unknown | untested | no |
| nodejs | 0.10.21 | — | unknown | untested | no |
| nodejs | 0.10.22 | — | unknown | untested | no |
| nodejs | 0.10.23 | — | unknown | untested | no |
| nodejs | 0.10.24 | — | unknown | untested | no |
| nodejs | 0.10.25 | — | unknown | untested | no |
| nodejs | 0.10.26 | — | unknown | untested | no |
| nodejs | 0.10.27 | — | unknown | untested | no |
| nodejs | 0.10.28 | — | unknown | untested | no |
| nodejs | 0.10.29 | — | unknown | untested | no |
| nodejs | 0.10.30 | — | unknown | untested | no |
| nodejs | 0.10.31 | — | unknown | untested | no |
| nodejs | 0.10.32 | — | unknown | untested | no |
| nodejs | 0.10.33 | — | unknown | untested | no |
| nodejs | 0.10.34 | — | unknown | untested | no |
| nodejs | 0.10.35 | — | unknown | untested | no |
| nodejs | 0.10.36 | — | unknown | untested | no |
| nodejs | 0.10.37 | — | unknown | untested | no |
| nodejs | 0.10.38 | — | unknown | untested | no |
| nodejs | 0.10.39 | — | unknown | untested | no |
| nodejs | 0.10.40 | — | unknown | untested | no |
| nodejs | 0.10.41 | — | unknown | untested | no |
| nodejs | 0.10.42 | — | unknown | untested | no |
| nodejs | 0.10.43 | — | unknown | untested | no |
| nodejs | 0.10.44 | — | unknown | untested | no |
| nodejs | 0.10.45 | — | unknown | untested | no |
| nodejs | 0.10.46 | — | unknown | untested | no |
| nodejs | 0.10.47 | — | unknown | untested | no |
| nodejs | 0.10.48 | — | unknown | untested | no |
| nodejs | 0.11.0 | — | unknown | untested | no |
| nodejs | 0.11.1 | — | unknown | untested | no |
| nodejs | 0.11.2 | — | unknown | untested | no |
| nodejs | 0.11.3 | — | unknown | untested | no |
| nodejs | 0.11.4 | — | unknown | untested | no |
| nodejs | 0.11.5 | — | unknown | untested | no |
| nodejs | 0.11.6 | — | unknown | untested | no |
| nodejs | 0.11.7 | — | unknown | untested | no |
| nodejs | 0.11.8 | — | unknown | untested | no |
| nodejs | 0.11.9 | — | unknown | untested | no |
| nodejs | 0.11.10 | — | unknown | untested | no |
| nodejs | 0.11.11 | — | unknown | untested | no |
| nodejs | 0.11.12 | — | unknown | untested | no |
| nodejs | 0.11.13 | — | unknown | untested | no |
| nodejs | 0.11.14 | — | unknown | untested | no |
| nodejs | 0.11.15 | — | unknown | untested | no |
| nodejs | 0.11.16 | — | unknown | untested | no |
| nodejs | 0.12.0 | — | unknown | untested | no |
| nodejs | 0.12.1 | — | unknown | untested | no |
| nodejs | 0.12.2 | — | unknown | untested | no |
| nodejs | 0.12.3 | — | unknown | untested | no |
| nodejs | 0.12.4 | — | unknown | untested | no |
| nodejs | 0.12.5 | — | unknown | untested | no |
| nodejs | 0.12.6 | — | unknown | untested | no |
| nodejs | 0.12.7 | — | unknown | untested | no |
| nodejs | 0.12.8 | — | unknown | untested | no |
| nodejs | 0.12.9 | — | unknown | untested | no |
| nodejs | 0.12.10 | — | unknown | untested | no |
| nodejs | 0.12.11 | — | unknown | untested | no |
| nodejs | 0.12.12 | — | unknown | untested | no |
| nodejs | 0.12.13 | — | unknown | untested | no |
| nodejs | 0.12.14 | — | unknown | untested | no |
| nodejs | 0.12.15 | — | unknown | untested | no |
| nodejs | 0.12.16 | — | unknown | untested | no |
| nodejs | 0.12.17 | — | unknown | untested | no |
| nodejs | 0.12.18 | — | unknown | untested | no |
| nodejs | 4.0.0 | — | end-of-life | untested | no |
| nodejs | 4.1.0 | — | end-of-life | untested | no |
| nodejs | 4.1.1 | — | end-of-life | untested | no |
| nodejs | 4.1.2 | — | end-of-life | untested | no |
| nodejs | 4.2.0 | — | end-of-life | untested | no |
| nodejs | 4.2.1 | — | end-of-life | untested | no |
| nodejs | 4.2.2 | — | end-of-life | untested | no |
| nodejs | 4.2.3 | — | end-of-life | untested | no |
| nodejs | 4.2.4 | — | end-of-life | untested | no |
| nodejs | 4.2.5 | — | end-of-life | untested | no |
| nodejs | 4.2.6 | — | end-of-life | untested | no |
| nodejs | 4.3.0 | — | end-of-life | untested | no |
| nodejs | 4.3.1 | — | end-of-life | untested | no |
| nodejs | 4.3.2 | — | end-of-life | untested | no |
| nodejs | 4.4.0 | — | end-of-life | untested | no |
| nodejs | 4.4.1 | — | end-of-life | untested | no |
| nodejs | 4.4.2 | — | end-of-life | untested | no |
| nodejs | 4.4.3 | — | end-of-life | untested | no |
| nodejs | 4.4.4 | — | end-of-life | untested | no |
| nodejs | 4.4.5 | — | end-of-life | untested | no |
| nodejs | 4.4.6 | — | end-of-life | untested | no |
| nodejs | 4.4.7 | — | end-of-life | untested | no |
| nodejs | 4.5.0 | — | end-of-life | untested | no |
| nodejs | 4.6.0 | — | end-of-life | untested | no |
| nodejs | 4.6.1 | — | end-of-life | untested | no |
| nodejs | 4.6.2 | — | end-of-life | untested | no |
| nodejs | 4.7.0 | — | end-of-life | untested | no |
| nodejs | 4.7.1 | — | end-of-life | untested | no |
| nodejs | 4.7.2 | — | end-of-life | untested | no |
| nodejs | 4.7.3 | — | end-of-life | untested | no |
| nodejs | 4.8.0 | — | end-of-life | untested | no |
| nodejs | 4.8.1 | — | end-of-life | untested | no |
| nodejs | 4.8.2 | — | end-of-life | untested | no |
| nodejs | 4.8.3 | — | end-of-life | untested | no |
| nodejs | 4.8.4 | — | end-of-life | untested | no |
| nodejs | 4.8.5 | — | end-of-life | untested | no |
| nodejs | 4.8.6 | — | end-of-life | untested | no |
| nodejs | 4.8.7 | — | end-of-life | untested | no |
| nodejs | 4.9.0 | — | end-of-life | untested | no |
| nodejs | 4.9.1 | — | end-of-life | untested | no |
| nodejs | 5.0.0 | — | end-of-life | untested | no |
| nodejs | 5.1.0 | — | end-of-life | untested | no |
| nodejs | 5.1.1 | — | end-of-life | untested | no |
| nodejs | 5.2.0 | — | end-of-life | untested | no |
| nodejs | 5.3.0 | — | end-of-life | untested | no |
| nodejs | 5.4.0 | — | end-of-life | untested | no |
| nodejs | 5.4.1 | — | end-of-life | untested | no |
| nodejs | 5.5.0 | — | end-of-life | untested | no |
| nodejs | 5.6.0 | — | end-of-life | untested | no |
| nodejs | 5.7.0 | — | end-of-life | untested | no |
| nodejs | 5.7.1 | — | end-of-life | untested | no |
| nodejs | 5.8.0 | — | end-of-life | untested | no |
| nodejs | 5.9.0 | — | end-of-life | untested | no |
| nodejs | 5.9.1 | — | end-of-life | untested | no |
| nodejs | 5.10.0 | — | end-of-life | untested | no |
| nodejs | 5.10.1 | — | end-of-life | untested | no |
| nodejs | 5.11.0 | — | end-of-life | untested | no |
| nodejs | 5.11.1 | — | end-of-life | untested | no |
| nodejs | 5.12.0 | — | end-of-life | untested | no |
| nodejs | 6.0.0 | — | end-of-life | untested | no |
| nodejs | 6.1.0 | — | end-of-life | untested | no |
| nodejs | 6.2.0 | — | end-of-life | untested | no |
| nodejs | 6.2.1 | — | end-of-life | untested | no |
| nodejs | 6.2.2 | — | end-of-life | untested | no |
| nodejs | 6.3.0 | — | end-of-life | untested | no |
| nodejs | 6.3.1 | — | end-of-life | untested | no |
| nodejs | 6.4.0 | — | end-of-life | untested | no |
| nodejs | 6.5.0 | — | end-of-life | untested | no |
| nodejs | 6.6.0 | — | end-of-life | untested | no |
| nodejs | 6.7.0 | — | end-of-life | untested | no |
| nodejs | 6.8.0 | — | end-of-life | untested | no |
| nodejs | 6.8.1 | — | end-of-life | untested | no |
| nodejs | 6.9.0 | — | end-of-life | untested | no |
| nodejs | 6.9.1 | — | end-of-life | untested | no |
| nodejs | 6.9.2 | — | end-of-life | untested | no |
| nodejs | 6.9.3 | — | end-of-life | untested | no |
| nodejs | 6.9.4 | — | end-of-life | untested | no |
| nodejs | 6.9.5 | — | end-of-life | untested | no |
| nodejs | 6.10.0 | — | end-of-life | untested | no |
| nodejs | 6.10.1 | — | end-of-life | untested | no |
| nodejs | 6.10.2 | — | end-of-life | untested | no |
| nodejs | 6.10.3 | — | end-of-life | untested | no |
| nodejs | 6.11.0 | — | end-of-life | untested | no |
| nodejs | 6.11.1 | — | end-of-life | untested | no |
| nodejs | 6.11.2 | — | end-of-life | untested | no |
| nodejs | 6.11.3 | — | end-of-life | untested | no |
| nodejs | 6.11.4 | — | end-of-life | untested | no |
| nodejs | 6.11.5 | — | end-of-life | untested | no |
| nodejs | 6.12.0 | — | end-of-life | untested | no |
| nodejs | 6.12.1 | — | end-of-life | untested | no |
| nodejs | 6.12.2 | — | end-of-life | untested | no |
| nodejs | 6.12.3 | — | end-of-life | untested | no |
| nodejs | 6.13.0 | — | end-of-life | untested | no |
| nodejs | 6.13.1 | — | end-of-life | untested | no |
| nodejs | 6.14.0 | — | end-of-life | untested | no |
| nodejs | 6.14.1 | — | end-of-life | untested | no |
| nodejs | 6.14.2 | — | end-of-life | untested | no |
| nodejs | 6.14.3 | — | end-of-life | untested | no |
| nodejs | 6.14.4 | — | end-of-life | untested | no |
| nodejs | 6.15.0 | — | end-of-life | untested | no |
| nodejs | 6.15.1 | — | end-of-life | untested | no |
| nodejs | 6.16.0 | — | end-of-life | untested | no |
| nodejs | 6.17.0 | — | end-of-life | untested | no |
| nodejs | 6.17.1 | — | end-of-life | untested | no |
| nodejs | 7.0.0 | — | end-of-life | untested | no |
| nodejs | 7.1.0 | — | end-of-life | untested | no |
| nodejs | 7.2.0 | — | end-of-life | untested | no |
| nodejs | 7.2.1 | — | end-of-life | untested | no |
| nodejs | 7.3.0 | — | end-of-life | untested | no |
| nodejs | 7.4.0 | — | end-of-life | untested | no |
| nodejs | 7.5.0 | — | end-of-life | untested | no |
| nodejs | 7.6.0 | — | end-of-life | untested | no |
| nodejs | 7.7.0 | — | end-of-life | untested | no |
| nodejs | 7.7.1 | — | end-of-life | untested | no |
| nodejs | 7.7.2 | — | end-of-life | untested | no |
| nodejs | 7.7.3 | — | end-of-life | untested | no |
| nodejs | 7.7.4 | — | end-of-life | untested | no |
| nodejs | 7.8.0 | — | end-of-life | untested | no |
| nodejs | 7.9.0 | — | end-of-life | untested | no |
| nodejs | 7.10.0 | — | end-of-life | untested | no |
| nodejs | 7.10.1 | — | end-of-life | untested | no |
| nodejs | 8.0.0 | — | end-of-life | untested | no |
| nodejs | 8.1.0 | — | end-of-life | untested | no |
| nodejs | 8.1.1 | — | end-of-life | untested | no |
| nodejs | 8.1.2 | — | end-of-life | untested | no |
| nodejs | 8.1.3 | — | end-of-life | untested | no |
| nodejs | 8.1.4 | — | end-of-life | untested | no |
| nodejs | 8.2.0 | — | end-of-life | untested | no |
| nodejs | 8.2.1 | — | end-of-life | untested | no |
| nodejs | 8.3.0 | — | end-of-life | untested | no |
| nodejs | 8.4.0 | — | end-of-life | untested | no |
| nodejs | 8.5.0 | — | end-of-life | untested | no |
| nodejs | 8.6.0 | — | end-of-life | untested | no |
| nodejs | 8.7.0 | — | end-of-life | untested | no |
| nodejs | 8.8.0 | — | end-of-life | untested | no |
| nodejs | 8.8.1 | — | end-of-life | untested | no |
| nodejs | 8.9.0 | — | end-of-life | untested | no |
| nodejs | 8.9.1 | — | end-of-life | untested | no |
| nodejs | 8.9.2 | — | end-of-life | untested | no |
| nodejs | 8.9.3 | — | end-of-life | untested | no |
| nodejs | 8.9.4 | — | end-of-life | untested | no |
| nodejs | 8.10.0 | — | end-of-life | untested | no |
| nodejs | 8.11.0 | — | end-of-life | untested | no |
| nodejs | 8.11.1 | — | end-of-life | untested | no |
| nodejs | 8.11.2 | — | end-of-life | untested | no |
| nodejs | 8.11.3 | — | end-of-life | untested | no |
| nodejs | 8.11.4 | — | end-of-life | untested | no |
| nodejs | 8.12.0 | — | end-of-life | untested | no |
| nodejs | 8.13.0 | — | end-of-life | untested | no |
| nodejs | 8.14.0 | — | end-of-life | untested | no |
| nodejs | 8.14.1 | — | end-of-life | untested | no |
| nodejs | 8.15.0 | — | end-of-life | untested | no |
| nodejs | 8.15.1 | — | end-of-life | untested | no |
| nodejs | 8.16.0 | — | end-of-life | untested | no |
| nodejs | 8.16.1 | — | end-of-life | untested | no |
| nodejs | 8.16.2 | — | end-of-life | untested | no |
| nodejs | 8.17.0 | — | end-of-life | untested | no |
| nodejs | 9.0.0 | — | end-of-life | untested | no |
| nodejs | 9.1.0 | — | end-of-life | untested | no |
| nodejs | 9.2.0 | — | end-of-life | untested | no |
| nodejs | 9.2.1 | — | end-of-life | untested | no |
| nodejs | 9.3.0 | — | end-of-life | untested | no |
| nodejs | 9.4.0 | — | end-of-life | untested | no |
| nodejs | 9.5.0 | — | end-of-life | untested | no |
| nodejs | 9.6.0 | — | end-of-life | untested | no |
| nodejs | 9.6.1 | — | end-of-life | untested | no |
| nodejs | 9.7.0 | — | end-of-life | untested | no |
| nodejs | 9.7.1 | — | end-of-life | untested | no |
| nodejs | 9.8.0 | — | end-of-life | untested | no |
| nodejs | 9.9.0 | — | end-of-life | untested | no |
| nodejs | 9.10.0 | — | end-of-life | untested | no |
| nodejs | 9.10.1 | — | end-of-life | untested | no |
| nodejs | 9.11.0 | — | end-of-life | untested | no |
| nodejs | 9.11.1 | — | end-of-life | untested | no |
| nodejs | 9.11.2 | — | end-of-life | untested | no |
| nodejs | 10.0.0 | — | end-of-life | untested | no |
| nodejs | 10.1.0 | — | end-of-life | untested | no |
| nodejs | 10.2.0 | — | end-of-life | untested | no |
| nodejs | 10.2.1 | — | end-of-life | untested | no |
| nodejs | 10.3.0 | — | end-of-life | untested | no |
| nodejs | 10.4.0 | — | end-of-life | untested | no |
| nodejs | 10.4.1 | — | end-of-life | untested | no |
| nodejs | 10.5.0 | — | end-of-life | untested | no |
| nodejs | 10.6.0 | — | end-of-life | untested | no |
| nodejs | 10.7.0 | — | end-of-life | untested | no |
| nodejs | 10.8.0 | — | end-of-life | untested | no |
| nodejs | 10.9.0 | — | end-of-life | untested | no |
| nodejs | 10.10.0 | — | end-of-life | untested | no |
| nodejs | 10.11.0 | — | end-of-life | untested | no |
| nodejs | 10.12.0 | — | end-of-life | untested | no |
| nodejs | 10.13.0 | — | end-of-life | untested | no |
| nodejs | 10.14.0 | — | end-of-life | untested | no |
| nodejs | 10.14.1 | — | end-of-life | untested | no |
| nodejs | 10.14.2 | — | end-of-life | untested | no |
| nodejs | 10.15.0 | — | end-of-life | untested | no |
| nodejs | 10.15.1 | — | end-of-life | untested | no |
| nodejs | 10.15.2 | — | end-of-life | untested | no |
| nodejs | 10.15.3 | — | end-of-life | untested | no |
| nodejs | 10.16.0 | — | end-of-life | untested | no |
| nodejs | 10.16.1 | — | end-of-life | untested | no |
| nodejs | 10.16.2 | — | end-of-life | untested | no |
| nodejs | 10.16.3 | — | end-of-life | untested | no |
| nodejs | 10.17.0 | — | end-of-life | untested | no |
| nodejs | 10.18.0 | — | end-of-life | untested | no |
| nodejs | 10.18.1 | — | end-of-life | untested | no |
| nodejs | 10.19.0 | — | end-of-life | untested | no |
| nodejs | 10.20.0 | — | end-of-life | untested | no |
| nodejs | 10.20.1 | — | end-of-life | untested | no |
| nodejs | 10.21.0 | — | end-of-life | untested | no |
| nodejs | 10.22.0 | — | end-of-life | untested | no |
| nodejs | 10.22.1 | — | end-of-life | untested | no |
| nodejs | 10.23.0 | — | end-of-life | untested | no |
| nodejs | 10.23.1 | — | end-of-life | untested | no |
| nodejs | 10.23.2 | — | end-of-life | untested | no |
| nodejs | 10.23.3 | — | end-of-life | untested | no |
| nodejs | 10.24.0 | — | end-of-life | untested | no |
| nodejs | 10.24.1 | — | end-of-life | untested | no |
| nodejs | 11.0.0 | — | end-of-life | untested | no |
| nodejs | 11.1.0 | — | end-of-life | untested | no |
| nodejs | 11.2.0 | — | end-of-life | untested | no |
| nodejs | 11.3.0 | — | end-of-life | untested | no |
| nodejs | 11.4.0 | — | end-of-life | untested | no |
| nodejs | 11.5.0 | — | end-of-life | untested | no |
| nodejs | 11.6.0 | — | end-of-life | untested | no |
| nodejs | 11.7.0 | — | end-of-life | untested | no |
| nodejs | 11.8.0 | — | end-of-life | untested | no |
| nodejs | 11.9.0 | — | end-of-life | untested | no |
| nodejs | 11.10.0 | — | end-of-life | untested | no |
| nodejs | 11.10.1 | — | end-of-life | untested | no |
| nodejs | 11.11.0 | — | end-of-life | untested | no |
| nodejs | 11.12.0 | — | end-of-life | untested | no |
| nodejs | 11.13.0 | — | end-of-life | untested | no |
| nodejs | 11.14.0 | — | end-of-life | untested | no |
| nodejs | 11.15.0 | — | end-of-life | untested | no |
| nodejs | 12.0.0 | — | end-of-life | untested | no |
| nodejs | 12.1.0 | — | end-of-life | untested | no |
| nodejs | 12.2.0 | — | end-of-life | untested | no |
| nodejs | 12.3.0 | — | end-of-life | untested | no |
| nodejs | 12.3.1 | — | end-of-life | untested | no |
| nodejs | 12.4.0 | — | end-of-life | untested | no |
| nodejs | 12.5.0 | — | end-of-life | untested | no |
| nodejs | 12.6.0 | — | end-of-life | untested | no |
| nodejs | 12.7.0 | — | end-of-life | untested | no |
| nodejs | 12.8.0 | — | end-of-life | untested | no |
| nodejs | 12.8.1 | — | end-of-life | untested | no |
| nodejs | 12.9.0 | — | end-of-life | untested | no |
| nodejs | 12.9.1 | — | end-of-life | untested | no |
| nodejs | 12.10.0 | — | end-of-life | untested | no |
| nodejs | 12.11.0 | — | end-of-life | untested | no |
| nodejs | 12.11.1 | — | end-of-life | untested | no |
| nodejs | 12.12.0 | — | end-of-life | untested | no |
| nodejs | 12.13.0 | — | end-of-life | untested | no |
| nodejs | 12.13.1 | — | end-of-life | untested | no |
| nodejs | 12.14.0 | — | end-of-life | untested | no |
| nodejs | 12.14.1 | — | end-of-life | untested | no |
| nodejs | 12.15.0 | — | end-of-life | untested | no |
| nodejs | 12.16.0 | — | end-of-life | untested | no |
| nodejs | 12.16.1 | — | end-of-life | untested | no |
| nodejs | 12.16.2 | — | end-of-life | untested | no |
| nodejs | 12.16.3 | — | end-of-life | untested | no |
| nodejs | 12.17.0 | — | end-of-life | untested | no |
| nodejs | 12.18.0 | — | end-of-life | untested | no |
| nodejs | 12.18.1 | — | end-of-life | untested | no |
| nodejs | 12.18.2 | — | end-of-life | untested | no |
| nodejs | 12.18.3 | — | end-of-life | untested | no |
| nodejs | 12.18.4 | — | end-of-life | untested | no |
| nodejs | 12.19.0 | — | end-of-life | untested | no |
| nodejs | 12.19.1 | — | end-of-life | untested | no |
| nodejs | 12.20.0 | — | end-of-life | untested | no |
| nodejs | 12.20.1 | — | end-of-life | untested | no |
| nodejs | 12.20.2 | — | end-of-life | untested | no |
| nodejs | 12.21.0 | — | end-of-life | untested | no |
| nodejs | 12.22.0 | — | end-of-life | untested | no |
| nodejs | 12.22.1 | — | end-of-life | untested | no |
| nodejs | 12.22.2 | — | end-of-life | untested | no |
| nodejs | 12.22.3 | — | end-of-life | untested | no |
| nodejs | 12.22.4 | — | end-of-life | untested | no |
| nodejs | 12.22.5 | — | end-of-life | untested | no |
| nodejs | 12.22.6 | — | end-of-life | untested | no |
| nodejs | 12.22.7 | — | end-of-life | untested | no |
| nodejs | 12.22.8 | — | end-of-life | untested | no |
| nodejs | 12.22.9 | — | end-of-life | untested | no |
| nodejs | 12.22.10 | — | end-of-life | untested | no |
| nodejs | 12.22.11 | — | end-of-life | untested | no |
| nodejs | 12.22.12 | — | end-of-life | untested | no |
| nodejs | 13.0.0 | — | end-of-life | untested | no |
| nodejs | 13.0.1 | — | end-of-life | untested | no |
| nodejs | 13.1.0 | — | end-of-life | untested | no |
| nodejs | 13.2.0 | — | end-of-life | untested | no |
| nodejs | 13.3.0 | — | end-of-life | untested | no |
| nodejs | 13.4.0 | — | end-of-life | untested | no |
| nodejs | 13.5.0 | — | end-of-life | untested | no |
| nodejs | 13.6.0 | — | end-of-life | untested | no |
| nodejs | 13.7.0 | — | end-of-life | untested | no |
| nodejs | 13.8.0 | — | end-of-life | untested | no |
| nodejs | 13.9.0 | — | end-of-life | untested | no |
| nodejs | 13.10.0 | — | end-of-life | untested | no |
| nodejs | 13.10.1 | — | end-of-life | untested | no |
| nodejs | 13.11.0 | — | end-of-life | untested | no |
| nodejs | 13.12.0 | — | end-of-life | untested | no |
| nodejs | 13.13.0 | — | end-of-life | untested | no |
| nodejs | 13.14.0 | — | end-of-life | untested | no |
| nodejs | 14.0.0 | — | end-of-life | untested | no |
| nodejs | 14.1.0 | — | end-of-life | untested | no |
| nodejs | 14.2.0 | — | end-of-life | untested | no |
| nodejs | 14.3.0 | — | end-of-life | untested | no |
| nodejs | 14.4.0 | — | end-of-life | untested | no |
| nodejs | 14.5.0 | — | end-of-life | untested | no |
| nodejs | 14.6.0 | — | end-of-life | untested | no |
| nodejs | 14.7.0 | — | end-of-life | untested | no |
| nodejs | 14.8.0 | — | end-of-life | untested | no |
| nodejs | 14.9.0 | — | end-of-life | untested | no |
| nodejs | 14.10.0 | — | end-of-life | untested | no |
| nodejs | 14.10.1 | — | end-of-life | untested | no |
| nodejs | 14.11.0 | — | end-of-life | untested | no |
| nodejs | 14.12.0 | — | end-of-life | untested | no |
| nodejs | 14.13.0 | — | end-of-life | untested | no |
| nodejs | 14.13.1 | — | end-of-life | untested | no |
| nodejs | 14.14.0 | — | end-of-life | untested | no |
| nodejs | 14.15.0 | — | end-of-life | untested | no |
| nodejs | 14.15.1 | — | end-of-life | untested | no |
| nodejs | 14.15.2 | — | end-of-life | untested | no |
| nodejs | 14.15.3 | — | end-of-life | untested | no |
| nodejs | 14.15.4 | — | end-of-life | untested | no |
| nodejs | 14.15.5 | — | end-of-life | untested | no |
| nodejs | 14.16.0 | — | end-of-life | untested | no |
| nodejs | 14.16.1 | — | end-of-life | untested | no |
| nodejs | 14.17.0 | — | end-of-life | untested | no |
| nodejs | 14.17.1 | — | end-of-life | untested | no |
| nodejs | 14.17.2 | — | end-of-life | untested | no |
| nodejs | 14.17.3 | — | end-of-life | untested | no |
| nodejs | 14.17.4 | — | end-of-life | untested | no |
| nodejs | 14.17.5 | — | end-of-life | untested | no |
| nodejs | 14.17.6 | — | end-of-life | untested | no |
| nodejs | 14.18.0 | — | end-of-life | untested | no |
| nodejs | 14.18.1 | — | end-of-life | untested | no |
| nodejs | 14.18.2 | — | end-of-life | untested | no |
| nodejs | 14.18.3 | — | end-of-life | untested | no |
| nodejs | 14.19.0 | — | end-of-life | untested | no |
| nodejs | 14.19.1 | — | end-of-life | untested | no |
| nodejs | 14.19.2 | — | end-of-life | untested | no |
| nodejs | 14.19.3 | — | end-of-life | untested | no |
| nodejs | 14.20.0 | — | end-of-life | untested | no |
| nodejs | 14.20.1 | — | end-of-life | untested | no |
| nodejs | 14.21.0 | — | end-of-life | untested | no |
| nodejs | 14.21.1 | — | end-of-life | untested | no |
| nodejs | 14.21.2 | — | end-of-life | untested | no |
| nodejs | 14.21.3 | — | end-of-life | untested | no |
| nodejs | 15.0.0 | — | end-of-life | untested | no |
| nodejs | 15.0.1 | — | end-of-life | untested | no |
| nodejs | 15.1.0 | — | end-of-life | untested | no |
| nodejs | 15.2.0 | — | end-of-life | untested | no |
| nodejs | 15.2.1 | — | end-of-life | untested | no |
| nodejs | 15.3.0 | — | end-of-life | untested | no |
| nodejs | 15.4.0 | — | end-of-life | untested | no |
| nodejs | 15.5.0 | — | end-of-life | untested | no |
| nodejs | 15.5.1 | — | end-of-life | untested | no |
| nodejs | 15.6.0 | — | end-of-life | untested | no |
| nodejs | 15.7.0 | — | end-of-life | untested | no |
| nodejs | 15.8.0 | — | end-of-life | untested | no |
| nodejs | 15.9.0 | — | end-of-life | untested | no |
| nodejs | 15.10.0 | — | end-of-life | untested | no |
| nodejs | 15.11.0 | — | end-of-life | untested | no |
| nodejs | 15.12.0 | — | end-of-life | untested | no |
| nodejs | 15.13.0 | — | end-of-life | untested | no |
| nodejs | 15.14.0 | — | end-of-life | untested | no |
| nodejs | 16.0.0 | — | end-of-life | untested | no |
| nodejs | 16.1.0 | — | end-of-life | untested | no |
| nodejs | 16.2.0 | — | end-of-life | untested | no |
| nodejs | 16.3.0 | — | end-of-life | untested | no |
| nodejs | 16.4.0 | — | end-of-life | untested | no |
| nodejs | 16.4.1 | — | end-of-life | untested | no |
| nodejs | 16.4.2 | — | end-of-life | untested | no |
| nodejs | 16.5.0 | — | end-of-life | untested | no |
| nodejs | 16.6.0 | — | end-of-life | untested | no |
| nodejs | 16.6.1 | — | end-of-life | untested | no |
| nodejs | 16.6.2 | — | end-of-life | untested | no |
| nodejs | 16.7.0 | — | end-of-life | untested | no |
| nodejs | 16.8.0 | — | end-of-life | untested | no |
| nodejs | 16.9.0 | — | end-of-life | untested | no |
| nodejs | 16.9.1 | — | end-of-life | untested | no |
| nodejs | 16.10.0 | — | end-of-life | untested | no |
| nodejs | 16.11.0 | — | end-of-life | untested | no |
| nodejs | 16.11.1 | — | end-of-life | untested | no |
| nodejs | 16.12.0 | — | end-of-life | untested | no |
| nodejs | 16.13.0 | — | end-of-life | untested | no |
| nodejs | 16.13.1 | — | end-of-life | untested | no |
| nodejs | 16.13.2 | — | end-of-life | untested | no |
| nodejs | 16.14.0 | — | end-of-life | untested | no |
| nodejs | 16.14.1 | — | end-of-life | untested | no |
| nodejs | 16.14.2 | — | end-of-life | untested | no |
| nodejs | 16.15.0 | — | end-of-life | untested | no |
| nodejs | 16.15.1 | — | end-of-life | untested | no |
| nodejs | 16.16.0 | — | end-of-life | untested | no |
| nodejs | 16.17.0 | — | end-of-life | untested | no |
| nodejs | 16.17.1 | — | end-of-life | untested | no |
| nodejs | 16.18.0 | — | end-of-life | untested | no |
| nodejs | 16.18.1 | — | end-of-life | untested | no |
| nodejs | 16.19.0 | — | end-of-life | untested | no |
| nodejs | 16.19.1 | — | end-of-life | untested | no |
| nodejs | 16.20.0 | — | end-of-life | untested | no |
| nodejs | 16.20.1 | — | end-of-life | untested | no |
| nodejs | 16.20.2 | — | end-of-life | untested | no |
| nodejs | 17.0.0 | — | end-of-life | untested | no |
| nodejs | 17.0.1 | — | end-of-life | untested | no |
| nodejs | 17.1.0 | — | end-of-life | untested | no |
| nodejs | 17.2.0 | — | end-of-life | untested | no |
| nodejs | 17.3.0 | — | end-of-life | untested | no |
| nodejs | 17.3.1 | — | end-of-life | untested | no |
| nodejs | 17.4.0 | — | end-of-life | untested | no |
| nodejs | 17.5.0 | — | end-of-life | untested | no |
| nodejs | 17.6.0 | — | end-of-life | untested | no |
| nodejs | 17.7.0 | — | end-of-life | untested | no |
| nodejs | 17.7.1 | — | end-of-life | untested | no |
| nodejs | 17.7.2 | — | end-of-life | untested | no |
| nodejs | 17.8.0 | — | end-of-life | untested | no |
| nodejs | 17.9.0 | — | end-of-life | untested | no |
| nodejs | 17.9.1 | — | end-of-life | untested | no |
| nodejs | 18.0.0 | — | end-of-life | untested | no |
| nodejs | 18.1.0 | — | end-of-life | untested | no |
| nodejs | 18.2.0 | — | end-of-life | untested | no |
| nodejs | 18.3.0 | — | end-of-life | untested | no |
| nodejs | 18.4.0 | — | end-of-life | untested | no |
| nodejs | 18.5.0 | — | end-of-life | untested | no |
| nodejs | 18.6.0 | — | end-of-life | untested | no |
| nodejs | 18.7.0 | — | end-of-life | untested | no |
| nodejs | 18.8.0 | — | end-of-life | untested | no |
| nodejs | 18.9.0 | — | end-of-life | untested | no |
| nodejs | 18.9.1 | — | end-of-life | untested | no |
| nodejs | 18.10.0 | — | end-of-life | untested | no |
| nodejs | 18.11.0 | — | end-of-life | untested | no |
| nodejs | 18.12.0 | — | end-of-life | untested | no |
| nodejs | 18.12.1 | — | end-of-life | untested | no |
| nodejs | 18.13.0 | — | end-of-life | untested | no |
| nodejs | 18.14.0 | — | end-of-life | untested | no |
| nodejs | 18.14.1 | — | end-of-life | untested | no |
| nodejs | 18.14.2 | — | end-of-life | untested | no |
| nodejs | 18.15.0 | — | end-of-life | untested | no |
| nodejs | 18.16.0 | — | end-of-life | untested | no |
| nodejs | 18.16.1 | — | end-of-life | untested | no |
| nodejs | 18.17.0 | — | end-of-life | untested | no |
| nodejs | 18.17.1 | — | end-of-life | untested | no |
| nodejs | 18.18.0 | — | end-of-life | untested | no |
| nodejs | 18.18.1 | — | end-of-life | untested | no |
| nodejs | 18.18.2 | — | end-of-life | untested | no |
| nodejs | 18.19.0 | — | end-of-life | untested | no |
| nodejs | 18.19.1 | — | end-of-life | untested | no |
| nodejs | 18.20.0 | — | end-of-life | untested | no |
| nodejs | 18.20.1 | — | end-of-life | untested | no |
| nodejs | 18.20.2 | — | end-of-life | untested | no |
| nodejs | 18.20.3 | — | end-of-life | untested | no |
| nodejs | 18.20.4 | — | end-of-life | untested | no |
| nodejs | 18.20.5 | — | end-of-life | untested | no |
| nodejs | 18.20.6 | — | end-of-life | untested | no |
| nodejs | 18.20.7 | — | end-of-life | untested | no |
| nodejs | 18.20.8 | — | end-of-life | untested | no |
| nodejs | 19.0.0 | — | end-of-life | untested | no |
| nodejs | 19.0.1 | — | end-of-life | untested | no |
| nodejs | 19.1.0 | — | end-of-life | untested | no |
| nodejs | 19.2.0 | — | end-of-life | untested | no |
| nodejs | 19.3.0 | — | end-of-life | untested | no |
| nodejs | 19.4.0 | — | end-of-life | untested | no |
| nodejs | 19.5.0 | — | end-of-life | untested | no |
| nodejs | 19.6.0 | — | end-of-life | untested | no |
| nodejs | 19.6.1 | — | end-of-life | untested | no |
| nodejs | 19.7.0 | — | end-of-life | untested | no |
| nodejs | 19.8.0 | — | end-of-life | untested | no |
| nodejs | 19.8.1 | — | end-of-life | untested | no |
| nodejs | 19.9.0 | — | end-of-life | untested | no |
| nodejs | 20.0.0 | — | end-of-life | untested | no |
| nodejs | 20.1.0 | — | end-of-life | untested | no |
| nodejs | 20.2.0 | — | end-of-life | untested | no |
| nodejs | 20.3.0 | — | end-of-life | untested | no |
| nodejs | 20.3.1 | — | end-of-life | untested | no |
| nodejs | 20.4.0 | — | end-of-life | untested | no |
| nodejs | 20.5.0 | — | end-of-life | untested | no |
| nodejs | 20.5.1 | — | end-of-life | untested | no |
| nodejs | 20.6.0 | — | end-of-life | untested | no |
| nodejs | 20.6.1 | — | end-of-life | untested | no |
| nodejs | 20.7.0 | — | end-of-life | untested | no |
| nodejs | 20.8.0 | — | end-of-life | untested | no |
| nodejs | 20.8.1 | — | end-of-life | untested | no |
| nodejs | 20.9.0 | — | end-of-life | untested | no |
| nodejs | 20.10.0 | — | end-of-life | untested | no |
| nodejs | 20.11.0 | — | end-of-life | untested | no |
| nodejs | 20.11.1 | — | end-of-life | untested | no |
| nodejs | 20.12.0 | — | end-of-life | untested | no |
| nodejs | 20.12.1 | — | end-of-life | untested | no |
| nodejs | 20.12.2 | — | end-of-life | untested | no |
| nodejs | 20.13.0 | — | end-of-life | untested | no |
| nodejs | 20.13.1 | — | end-of-life | untested | no |
| nodejs | 20.14.0 | — | end-of-life | untested | no |
| nodejs | 20.15.0 | — | end-of-life | untested | no |
| nodejs | 20.15.1 | — | end-of-life | untested | no |
| nodejs | 20.16.0 | — | end-of-life | untested | no |
| nodejs | 20.17.0 | — | end-of-life | untested | no |
| nodejs | 20.18.0 | — | end-of-life | untested | no |
| nodejs | 20.18.1 | — | end-of-life | untested | no |
| nodejs | 20.18.2 | — | end-of-life | untested | no |
| nodejs | 20.18.3 | — | end-of-life | untested | no |
| nodejs | 20.19.0 | — | end-of-life | untested | no |
| nodejs | 20.19.1 | — | end-of-life | untested | no |
| nodejs | 20.19.2 | — | end-of-life | untested | no |
| nodejs | 20.19.3 | — | end-of-life | untested | no |
| nodejs | 20.19.4 | — | end-of-life | untested | no |
| nodejs | 20.19.5 | — | end-of-life | untested | no |
| nodejs | 20.19.6 | — | end-of-life | untested | no |
| nodejs | 20.20.0 | — | end-of-life | untested | no |
| nodejs | 20.20.1 | — | end-of-life | untested | no |
| nodejs | 20.20.2 | — | end-of-life | untested | no |
| nodejs | 21.0.0 | — | end-of-life | untested | no |
| nodejs | 21.1.0 | — | end-of-life | untested | no |
| nodejs | 21.2.0 | — | end-of-life | untested | no |
| nodejs | 21.3.0 | — | end-of-life | untested | no |
| nodejs | 21.4.0 | — | end-of-life | untested | no |
| nodejs | 21.5.0 | — | end-of-life | untested | no |
| nodejs | 21.6.0 | — | end-of-life | untested | no |
| nodejs | 21.6.1 | — | end-of-life | untested | no |
| nodejs | 21.6.2 | — | end-of-life | untested | no |
| nodejs | 21.7.0 | — | end-of-life | untested | no |
| nodejs | 21.7.1 | — | end-of-life | untested | no |
| nodejs | 21.7.2 | — | end-of-life | untested | no |
| nodejs | 21.7.3 | — | end-of-life | untested | no |
| nodejs | 22.0.0 | — | maintained | untested | no |
| nodejs | 22.1.0 | — | maintained | untested | no |
| nodejs | 22.2.0 | — | maintained | untested | no |
| nodejs | 22.3.0 | — | maintained | untested | no |
| nodejs | 22.4.0 | — | maintained | untested | no |
| nodejs | 22.4.1 | — | maintained | untested | no |
| nodejs | 22.5.0 | — | maintained | untested | no |
| nodejs | 22.5.1 | — | maintained | untested | no |
| nodejs | 22.6.0 | — | maintained | untested | no |
| nodejs | 22.7.0 | — | maintained | untested | no |
| nodejs | 22.8.0 | — | maintained | untested | no |
| nodejs | 22.9.0 | — | maintained | untested | no |
| nodejs | 22.10.0 | — | maintained | untested | no |
| nodejs | 22.11.0 | — | maintained | untested | no |
| nodejs | 22.12.0 | — | maintained | untested | no |
| nodejs | 22.13.0 | — | maintained | untested | no |
| nodejs | 22.13.1 | — | maintained | untested | no |
| nodejs | 22.14.0 | — | maintained | untested | no |
| nodejs | 22.15.0 | — | maintained | untested | no |
| nodejs | 22.15.1 | — | maintained | untested | no |
| nodejs | 22.16.0 | — | maintained | untested | no |
| nodejs | 22.17.0 | — | maintained | untested | no |
| nodejs | 22.17.1 | — | maintained | untested | no |
| nodejs | 22.18.0 | — | maintained | untested | no |
| nodejs | 22.19.0 | — | maintained | untested | no |
| nodejs | 22.20.0 | — | maintained | untested | no |
| nodejs | 22.21.0 | — | maintained | untested | no |
| nodejs | 22.21.1 | — | maintained | untested | no |
| nodejs | 22.22.0 | — | maintained | untested | no |
| nodejs | 22.22.1 | — | maintained | untested | no |
| nodejs | 22.22.2 | — | maintained | untested | no |
| nodejs | 22.22.3 | — | maintained | untested | no |
| nodejs | 22.23.0 | — | maintained | untested | no |
| nodejs | 22.23.1 | — | maintained | untested | no |
| nodejs | 22.23.2 | — | maintained | untested | no |
| nodejs | 22.23.3 | — | maintained | passed 2026-10-06 | yes |
| nodejs | 23.0.0 | — | end-of-life | untested | no |
| nodejs | 23.1.0 | — | end-of-life | untested | no |
| nodejs | 23.2.0 | — | end-of-life | untested | no |
| nodejs | 23.3.0 | — | end-of-life | untested | no |
| nodejs | 23.4.0 | — | end-of-life | untested | no |
| nodejs | 23.5.0 | — | end-of-life | untested | no |
| nodejs | 23.6.0 | — | end-of-life | untested | no |
| nodejs | 23.6.1 | — | end-of-life | untested | no |
| nodejs | 23.7.0 | — | end-of-life | untested | no |
| nodejs | 23.8.0 | — | end-of-life | untested | no |
| nodejs | 23.9.0 | — | end-of-life | untested | no |
| nodejs | 23.10.0 | — | end-of-life | untested | no |
| nodejs | 23.11.0 | — | end-of-life | untested | no |
| nodejs | 23.11.1 | — | end-of-life | untested | no |
| nodejs | 24.0.0 | — | maintained | untested | no |
| nodejs | 24.0.1 | — | maintained | untested | no |
| nodejs | 24.0.2 | — | maintained | untested | no |
| nodejs | 24.1.0 | — | maintained | untested | no |
| nodejs | 24.2.0 | — | maintained | untested | no |
| nodejs | 24.3.0 | — | maintained | untested | no |
| nodejs | 24.4.0 | — | maintained | untested | no |
| nodejs | 24.4.1 | — | maintained | untested | no |
| nodejs | 24.5.0 | — | maintained | untested | no |
| nodejs | 24.6.0 | — | maintained | untested | no |
| nodejs | 24.7.0 | — | maintained | untested | no |
| nodejs | 24.8.0 | — | maintained | untested | no |
| nodejs | 24.9.0 | — | maintained | untested | no |
| nodejs | 24.10.0 | — | maintained | untested | no |
| nodejs | 24.11.0 | — | maintained | untested | no |
| nodejs | 24.11.1 | — | maintained | untested | no |
| nodejs | 24.12.0 | — | maintained | untested | no |
| nodejs | 24.13.0 | — | maintained | untested | no |
| nodejs | 24.13.1 | — | maintained | untested | no |
| nodejs | 24.14.0 | — | maintained | untested | no |
| nodejs | 24.14.1 | — | maintained | untested | no |
| nodejs | 24.15.0 | — | maintained | untested | no |
| nodejs | 24.16.0 | — | maintained | untested | no |
| nodejs | 24.17.0 | — | maintained | untested | no |
| nodejs | 24.18.0 | — | maintained | untested | no |
| nodejs | 24.18.1 | — | maintained | untested | no |
| nodejs | 24.19.0 | — | maintained | untested | no |
| nodejs | 24.20.0 | — | maintained | untested | no |
| nodejs | 24.21.0 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 25.0.0 | — | end-of-life | untested | no |
| nodejs | 25.1.0 | — | end-of-life | untested | no |
| nodejs | 25.2.0 | — | end-of-life | untested | no |
| nodejs | 25.2.1 | — | end-of-life | untested | no |
| nodejs | 25.3.0 | — | end-of-life | untested | no |
| nodejs | 25.4.0 | — | end-of-life | untested | no |
| nodejs | 25.5.0 | — | end-of-life | untested | no |
| nodejs | 25.6.0 | — | end-of-life | untested | no |
| nodejs | 25.6.1 | — | end-of-life | untested | no |
| nodejs | 25.7.0 | — | end-of-life | untested | no |
| nodejs | 25.8.0 | — | end-of-life | untested | no |
| nodejs | 25.8.1 | — | end-of-life | untested | no |
| nodejs | 25.8.2 | — | end-of-life | untested | no |
| nodejs | 25.9.0 | — | end-of-life | untested | no |
| nodejs | 26.0.0 | — | maintained | untested | no |
| nodejs | 26.1.0 | — | maintained | untested | no |
| nodejs | 26.2.0 | — | maintained | untested | no |
| nodejs | 26.3.0 | — | maintained | untested | no |
| nodejs | 26.3.1 | — | maintained | untested | no |
| nodejs | 26.4.0 | — | maintained | untested | no |
| nodejs | 26.5.0 | — | maintained | untested | no |
| nodejs | 26.5.1 | — | maintained | untested | no |
| nodejs | 26.6.0 | — | maintained | untested | no |
| nodejs | 26.7.0 | — | maintained | untested | no |
| nodejs | 26.8.0 | — | maintained | untested | no |
| nodejs | 26.8.1 | — | maintained | untested | no |
| nodejs | 26.8.2 | — | maintained | untested | no |
| nodejs | 26.9.0 | — | maintained | untested | no |
| nodejs | 26.10.0 | — | maintained | passed 2026-10-06 | yes |
heroku-python
Stack: heroku-22 · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/heroku-python:clusterbuilder-heroku-python@sha256:b4c9ceb9f6aef945b1b3e1d80c947937cc9db8e7d4b81a5caa482c941cd189c6
Buildpacks: heroku/python@6.7.0, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 3.14.8.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| python | 3.11.17 | — | maintained | passed 2026-10-06 | yes |
| python | 3.12.15 | — | maintained | passed 2026-10-06 | yes |
| python | 3.13.16 | — | maintained | passed 2026-10-06 | yes |
| python | 3.14.8 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| python | 3.10.0 | — | end-of-life | untested | no |
| python | 3.10.1 | — | end-of-life | untested | no |
| python | 3.10.2 | — | end-of-life | untested | no |
| python | 3.10.3 | — | end-of-life | untested | no |
| python | 3.10.4 | — | end-of-life | untested | no |
| python | 3.10.5 | — | end-of-life | untested | no |
| python | 3.10.6 | — | end-of-life | untested | no |
| python | 3.10.7 | — | end-of-life | untested | no |
| python | 3.10.8 | — | end-of-life | untested | no |
| python | 3.10.9 | — | end-of-life | untested | no |
| python | 3.10.10 | — | end-of-life | untested | no |
| python | 3.10.11 | — | end-of-life | untested | no |
| python | 3.10.12 | — | end-of-life | untested | no |
| python | 3.10.13 | — | end-of-life | untested | no |
| python | 3.10.14 | — | end-of-life | untested | no |
| python | 3.10.15 | — | end-of-life | untested | no |
| python | 3.10.16 | — | end-of-life | untested | no |
| python | 3.10.17 | — | end-of-life | untested | no |
| python | 3.10.18 | — | end-of-life | untested | no |
| python | 3.10.19 | — | end-of-life | untested | no |
| python | 3.10.20 | — | end-of-life | untested | no |
| python | 3.10.21 | — | end-of-life | untested | no |
| python | 3.10.22 | — | end-of-life | untested | no |
| python | 3.11.0 | — | maintained | untested | no |
| python | 3.11.1 | — | maintained | untested | no |
| python | 3.11.2 | — | maintained | untested | no |
| python | 3.11.3 | — | maintained | untested | no |
| python | 3.11.4 | — | maintained | untested | no |
| python | 3.11.5 | — | maintained | untested | no |
| python | 3.11.6 | — | maintained | untested | no |
| python | 3.11.7 | — | maintained | untested | no |
| python | 3.11.8 | — | maintained | untested | no |
| python | 3.11.9 | — | maintained | untested | no |
| python | 3.11.10 | — | maintained | untested | no |
| python | 3.11.11 | — | maintained | untested | no |
| python | 3.11.12 | — | maintained | untested | no |
| python | 3.11.13 | — | maintained | untested | no |
| python | 3.11.14 | — | maintained | untested | no |
| python | 3.11.15 | — | maintained | untested | no |
| python | 3.11.16 | — | maintained | untested | no |
| python | 3.11.17 | — | maintained | passed 2026-10-06 | yes |
| python | 3.12.0 | — | maintained | untested | no |
| python | 3.12.1 | — | maintained | untested | no |
| python | 3.12.2 | — | maintained | untested | no |
| python | 3.12.3 | — | maintained | untested | no |
| python | 3.12.4 | — | maintained | untested | no |
| python | 3.12.5 | — | maintained | untested | no |
| python | 3.12.6 | — | maintained | untested | no |
| python | 3.12.7 | — | maintained | untested | no |
| python | 3.12.8 | — | maintained | untested | no |
| python | 3.12.9 | — | maintained | untested | no |
| python | 3.12.10 | — | maintained | untested | no |
| python | 3.12.11 | — | maintained | untested | no |
| python | 3.12.12 | — | maintained | untested | no |
| python | 3.12.13 | — | maintained | untested | no |
| python | 3.12.14 | — | maintained | untested | no |
| python | 3.12.15 | — | maintained | passed 2026-10-06 | yes |
| python | 3.13.0 | — | maintained | untested | no |
| python | 3.13.1 | — | maintained | untested | no |
| python | 3.13.2 | — | maintained | untested | no |
| python | 3.13.3 | — | maintained | untested | no |
| python | 3.13.4 | — | maintained | untested | no |
| python | 3.13.5 | — | maintained | untested | no |
| python | 3.13.6 | — | maintained | untested | no |
| python | 3.13.7 | — | maintained | untested | no |
| python | 3.13.8 | — | maintained | untested | no |
| python | 3.13.9 | — | maintained | untested | no |
| python | 3.13.10 | — | maintained | untested | no |
| python | 3.13.11 | — | maintained | untested | no |
| python | 3.13.12 | — | maintained | untested | no |
| python | 3.13.13 | — | maintained | untested | no |
| python | 3.13.14 | — | maintained | untested | no |
| python | 3.13.15 | — | maintained | untested | no |
| python | 3.13.16 | — | maintained | passed 2026-10-06 | yes |
| python | 3.14.0 | — | maintained | untested | no |
| python | 3.14.1 | — | maintained | untested | no |
| python | 3.14.2 | — | maintained | untested | no |
| python | 3.14.3 | — | maintained | untested | no |
| python | 3.14.4 | — | maintained | untested | no |
| python | 3.14.5 | — | maintained | untested | no |
| python | 3.14.6 | — | maintained | untested | no |
| python | 3.14.7 | — | maintained | untested | no |
| python | 3.14.8 (default) | — | maintained | passed 2026-10-06 | yes |
heroku-ruby
Stack: heroku-22 · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/heroku-ruby:clusterbuilder-heroku-ruby@sha256:8cfb7849a964f4b84d88ff4ee0788819e730d0582238606d9191d28d4b250b9c
Buildpacks: heroku/ruby@12.4.2, heroku/procfile@4.2.4, d2o/manifest@0.1.0.
Default selection: 3.3.9.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| ruby | 3.3.9 (default) | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.3.12 | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.4.11 | — | maintained | passed 2026-10-06 | yes |
| ruby | 4.0.7 | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| ruby | 3.1.0 | — | end-of-life | untested | no |
| ruby | 3.1.1 | — | end-of-life | untested | no |
| ruby | 3.1.2 | — | end-of-life | untested | no |
| ruby | 3.1.3 | — | end-of-life | untested | no |
| ruby | 3.1.4 | — | end-of-life | untested | no |
| ruby | 3.1.5 | — | end-of-life | untested | no |
| ruby | 3.1.6 | — | end-of-life | untested | no |
| ruby | 3.1.7 | — | end-of-life | untested | no |
| ruby | 3.2.0 | — | end-of-life | untested | no |
| ruby | 3.2.1 | — | end-of-life | untested | no |
| ruby | 3.2.2 | — | end-of-life | untested | no |
| ruby | 3.2.3 | — | end-of-life | untested | no |
| ruby | 3.2.4 | — | end-of-life | untested | no |
| ruby | 3.2.5 | — | end-of-life | untested | no |
| ruby | 3.2.6 | — | end-of-life | untested | no |
| ruby | 3.2.7 | — | end-of-life | untested | no |
| ruby | 3.2.8 | — | end-of-life | untested | no |
| ruby | 3.2.9 | — | end-of-life | untested | no |
| ruby | 3.2.10 | — | end-of-life | untested | no |
| ruby | 3.2.11 | — | end-of-life | untested | no |
| ruby | 3.3.0 | — | maintained | untested | no |
| ruby | 3.3.1 | — | maintained | untested | no |
| ruby | 3.3.2 | — | maintained | untested | no |
| ruby | 3.3.3 | — | maintained | untested | no |
| ruby | 3.3.4 | — | maintained | untested | no |
| ruby | 3.3.5 | — | maintained | untested | no |
| ruby | 3.3.6 | — | maintained | untested | no |
| ruby | 3.3.7 | — | maintained | untested | no |
| ruby | 3.3.8 | — | maintained | untested | no |
| ruby | 3.3.9 (default) | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.3.10 | — | maintained | untested | no |
| ruby | 3.3.11 | — | maintained | untested | no |
| ruby | 3.3.12 | — | maintained | passed 2026-10-06 | yes |
| ruby | 3.4.0 | — | maintained | untested | no |
| ruby | 3.4.1 | — | maintained | untested | no |
| ruby | 3.4.2 | — | maintained | untested | no |
| ruby | 3.4.3 | — | maintained | untested | no |
| ruby | 3.4.4 | — | maintained | untested | no |
| ruby | 3.4.5 | — | maintained | untested | no |
| ruby | 3.4.6 | — | maintained | untested | no |
| ruby | 3.4.7 | — | maintained | untested | no |
| ruby | 3.4.8 | — | maintained | untested | no |
| ruby | 3.4.9 | — | maintained | untested | no |
| ruby | 3.4.10 | — | maintained | untested | no |
| ruby | 3.4.11 | — | maintained | passed 2026-10-06 | yes |
| ruby | 4.0.0 | — | maintained | untested | no |
| ruby | 4.0.1 | — | maintained | untested | no |
| ruby | 4.0.2 | — | maintained | untested | no |
| ruby | 4.0.3 | — | maintained | untested | no |
| ruby | 4.0.4 | — | maintained | untested | no |
| ruby | 4.0.5 | — | maintained | untested | no |
| ruby | 4.0.6 | — | maintained | untested | no |
| ruby | 4.0.7 | — | maintained | passed 2026-10-06 | yes |
Availability could not be confirmed for 93 additional upstream releases (download server returned 403); these are retained as unconfirmed in the JSON catalog.
paketo-rust
Stack: heroku-22 · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/paketo-rust:clusterbuilder-paketo-rust@sha256:203abff9d175446c282c48add710fe54aa527045dde532cdddfaa4d985949081
Buildpacks: paketo-community/rustup@1.17.0, paketo-community/rust@0.90.0, paketo-community/rust-dist@1.50.0, paketo-community/cargo@0.16.0, paketo-buildpacks/syft@2.41.0, paketo-buildpacks/procfile@5.13.7, d2o/manifest@0.1.0.
Default selection: stable.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| rust | 1.99.0 (default) | — | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| rust | 1.19.0 | — | end-of-life | untested | no |
| rust | 1.20.0 | — | end-of-life | untested | no |
| rust | 1.21.0 | — | end-of-life | untested | no |
| rust | 1.22.0 | — | end-of-life | untested | no |
| rust | 1.22.1 | — | end-of-life | untested | no |
| rust | 1.23.0 | — | end-of-life | untested | no |
| rust | 1.24.0 | — | end-of-life | untested | no |
| rust | 1.24.1 | — | end-of-life | untested | no |
| rust | 1.25.0 | — | end-of-life | untested | no |
| rust | 1.26.0 | — | end-of-life | untested | no |
| rust | 1.26.1 | — | end-of-life | untested | no |
| rust | 1.26.2 | — | end-of-life | untested | no |
| rust | 1.27.0 | — | end-of-life | untested | no |
| rust | 1.27.1 | — | end-of-life | untested | no |
| rust | 1.27.2 | — | end-of-life | untested | no |
| rust | 1.28.0 | — | end-of-life | untested | no |
| rust | 1.29.0 | — | end-of-life | untested | no |
| rust | 1.29.1 | — | end-of-life | untested | no |
| rust | 1.29.2 | — | end-of-life | untested | no |
| rust | 1.30.0 | — | end-of-life | untested | no |
| rust | 1.30.1 | — | end-of-life | untested | no |
| rust | 1.31.0 | — | end-of-life | untested | no |
| rust | 1.31.1 | — | end-of-life | untested | no |
| rust | 1.32.0 | — | end-of-life | untested | no |
| rust | 1.33.0 | — | end-of-life | untested | no |
| rust | 1.34.0 | — | end-of-life | untested | no |
| rust | 1.34.1 | — | end-of-life | untested | no |
| rust | 1.34.2 | — | end-of-life | untested | no |
| rust | 1.35.0 | — | end-of-life | untested | no |
| rust | 1.36.0 | — | end-of-life | untested | no |
| rust | 1.37.0 | — | end-of-life | untested | no |
| rust | 1.38.0 | — | end-of-life | untested | no |
| rust | 1.39.0 | — | end-of-life | untested | no |
| rust | 1.40.0 | — | end-of-life | untested | no |
| rust | 1.41.0 | — | end-of-life | untested | no |
| rust | 1.41.1 | — | end-of-life | untested | no |
| rust | 1.42.0 | — | end-of-life | untested | no |
| rust | 1.43.0 | — | end-of-life | untested | no |
| rust | 1.43.1 | — | end-of-life | untested | no |
| rust | 1.44.0 | — | end-of-life | untested | no |
| rust | 1.44.1 | — | end-of-life | untested | no |
| rust | 1.45.0 | — | end-of-life | untested | no |
| rust | 1.45.1 | — | end-of-life | untested | no |
| rust | 1.45.2 | — | end-of-life | untested | no |
| rust | 1.46.0 | — | end-of-life | untested | no |
| rust | 1.47.0 | — | end-of-life | untested | no |
| rust | 1.48.0 | — | end-of-life | untested | no |
| rust | 1.49.0 | — | end-of-life | untested | no |
| rust | 1.50.0 | — | end-of-life | untested | no |
| rust | 1.51.0 | — | end-of-life | untested | no |
| rust | 1.52.0 | — | end-of-life | untested | no |
| rust | 1.52.1 | — | end-of-life | untested | no |
| rust | 1.53.0 | — | end-of-life | untested | no |
| rust | 1.54.0 | — | end-of-life | untested | no |
| rust | 1.55.0 | — | end-of-life | untested | no |
| rust | 1.56.0 | — | end-of-life | untested | no |
| rust | 1.56.1 | — | end-of-life | untested | no |
| rust | 1.57.0 | — | end-of-life | untested | no |
| rust | 1.58.0 | — | end-of-life | untested | no |
| rust | 1.58.1 | — | end-of-life | untested | no |
| rust | 1.59.0 | — | end-of-life | untested | no |
| rust | 1.60.0 | — | end-of-life | untested | no |
| rust | 1.61.0 | — | end-of-life | untested | no |
| rust | 1.62.0 | — | end-of-life | untested | no |
| rust | 1.62.1 | — | end-of-life | untested | no |
| rust | 1.63.0 | — | end-of-life | untested | no |
| rust | 1.64.0 | — | end-of-life | untested | no |
| rust | 1.65.0 | — | end-of-life | untested | no |
| rust | 1.66.0 | — | end-of-life | untested | no |
| rust | 1.66.1 | — | end-of-life | untested | no |
| rust | 1.67.0 | — | end-of-life | untested | no |
| rust | 1.67.1 | — | end-of-life | untested | no |
| rust | 1.68.0 | — | end-of-life | untested | no |
| rust | 1.68.1 | — | end-of-life | untested | no |
| rust | 1.68.2 | — | end-of-life | untested | no |
| rust | 1.69.0 | — | end-of-life | untested | no |
| rust | 1.70.0 | — | end-of-life | untested | no |
| rust | 1.71.0 | — | end-of-life | untested | no |
| rust | 1.71.1 | — | end-of-life | untested | no |
| rust | 1.72.0 | — | end-of-life | untested | no |
| rust | 1.72.1 | — | end-of-life | untested | no |
| rust | 1.73.0 | — | end-of-life | untested | no |
| rust | 1.74.0 | — | end-of-life | untested | no |
| rust | 1.74.1 | — | end-of-life | untested | no |
| rust | 1.75.0 | — | end-of-life | untested | no |
| rust | 1.76.0 | — | end-of-life | untested | no |
| rust | 1.77.0 | — | end-of-life | untested | no |
| rust | 1.77.1 | — | end-of-life | untested | no |
| rust | 1.77.2 | — | end-of-life | untested | no |
| rust | 1.78.0 | — | end-of-life | untested | no |
| rust | 1.79.0 | — | end-of-life | untested | no |
| rust | 1.80.0 | — | end-of-life | untested | no |
| rust | 1.80.1 | — | end-of-life | untested | no |
| rust | 1.81.0 | — | end-of-life | untested | no |
| rust | 1.82.0 | — | end-of-life | untested | no |
| rust | 1.83.0 | — | end-of-life | untested | no |
| rust | 1.84.0 | — | end-of-life | untested | no |
| rust | 1.84.1 | — | end-of-life | untested | no |
| rust | 1.85.0 | — | end-of-life | untested | no |
| rust | 1.85.1 | — | end-of-life | untested | no |
| rust | 1.86.0 | — | end-of-life | untested | no |
| rust | 1.87.0 | — | end-of-life | untested | no |
| rust | 1.88.0 | — | end-of-life | untested | no |
| rust | 1.89.0 | — | end-of-life | untested | no |
| rust | 1.90.0 | — | end-of-life | untested | no |
| rust | 1.91.0 | — | end-of-life | untested | no |
| rust | 1.91.1 | — | end-of-life | untested | no |
| rust | 1.92.0 | — | end-of-life | untested | no |
| rust | 1.93.0 | — | end-of-life | untested | no |
| rust | 1.93.1 | — | end-of-life | untested | no |
| rust | 1.94.0 | — | end-of-life | untested | no |
| rust | 1.94.1 | — | end-of-life | untested | no |
| rust | 1.95.0 | — | end-of-life | untested | no |
| rust | 1.96.0 | — | end-of-life | untested | no |
| rust | 1.96.1 | — | end-of-life | untested | no |
| rust | 1.97.0 | — | end-of-life | untested | no |
| rust | 1.97.1 | — | end-of-life | untested | no |
| rust | 1.98.0 | — | end-of-life | untested | no |
| rust | 1.98.1 | — | end-of-life | untested | no |
| rust | 1.99.0 (default) | — | maintained | passed 2026-10-06 | yes |
paketo-web-servers
Stack: io.buildpacks.stacks.jammy · architecture: amd64.
Builder: registry.stag.d2o.io/library/kpack/paketo-web-servers:clusterbuilder-paketo-web-servers@sha256:695bc2754da25ee51db1e41df3ce9bc3799bd3414cd4f620a0aeb2cf7b144bcd
Buildpacks: paketo-buildpacks/npm-install@2.3.30, paketo-buildpacks/yarn-install@2.7.25, paketo-buildpacks/node-engine@8.8.1, paketo-buildpacks/nginx@1.3.0, paketo-buildpacks/httpd@1.1.6, paketo-buildpacks/yarn@2.5.0, paketo-buildpacks/ca-certificates@3.14.1, paketo-buildpacks/watchexec@3.11.1, paketo-buildpacks/node-run-script@2.3.55, paketo-buildpacks/procfile@5.15.2, paketo-buildpacks/image-labels@4.14.2, paketo-buildpacks/environment-variables@4.12.0, paketo-buildpacks/source-removal@1.0.43, paketo-buildpacks/web-servers@4.14.1, d2o/manifest@0.1.0.
Default selection: nginx; public/; no frontend build unless configured.
Supported and verified versions
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| nginx | 1.30.5 | — | maintained | passed 2026-10-06 | yes |
| nginx | 1.31.6 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 22.23.3 | 10.9.9 | maintained | passed 2026-10-06 | yes |
| nodejs | 24.21.0 (default) | 11.19.0 | maintained | passed 2026-10-06 | yes |
| nodejs | 26.10.0 | 11.19.1 | maintained | passed 2026-10-06 | yes |
Full available inventory, including untested and end-of-life releases
| Runtime | Version | Bundled npm | Upstream status | d2o verification | Supported |
|---|---|---|---|---|---|
| berry | 4.18.0 | — | unknown | untested | no |
| berry | 4.18.1 | — | unknown | untested | no |
| nginx | 1.30.4 | — | maintained | untested | no |
| nginx | 1.30.5 | — | maintained | passed 2026-10-06 | yes |
| nginx | 1.31.5 | — | maintained | untested | no |
| nginx | 1.31.6 (default) | — | maintained | passed 2026-10-06 | yes |
| nodejs | 18.20.7 | 10.8.2 | end-of-life | untested | no |
| nodejs | 18.20.8 | 10.8.2 | end-of-life | untested | no |
| nodejs | 20.20.1 | 10.8.2 | end-of-life | untested | no |
| nodejs | 20.20.2 | 10.8.2 | end-of-life | untested | no |
| nodejs | 22.23.2 | 10.9.8 | maintained | untested | no |
| nodejs | 22.23.3 | 10.9.9 | maintained | passed 2026-10-06 | yes |
| nodejs | 24.20.0 | 11.19.0 | maintained | untested | no |
| nodejs | 24.21.0 (default) | 11.19.0 | maintained | passed 2026-10-06 | yes |
| nodejs | 26.9.0 | 11.19.1 | maintained | untested | no |
| nodejs | 26.10.0 | 11.19.1 | maintained | passed 2026-10-06 | yes |
| yarn | 1.22.19 | — | unknown | untested | no |
| yarn | 1.22.22 (default) | — | unknown | passed 2026-10-06 | no |
Sources
The machine-readable catalog records artifact URLs and source checksums. Maintenance and inventory sources:
- https://codeload.github.com/heroku/buildpacks-go/tar.gz/refs/tags/v4.1.1
- https://codeload.github.com/heroku/buildpacks-nodejs/tar.gz/refs/tags/v5.8.1
- https://codeload.github.com/heroku/buildpacks-python/tar.gz/refs/tags/v6.7.0
- https://codeload.github.com/heroku/buildpacks-ruby/tar.gz/refs/tags/v12.4.2
- https://go.dev/dl/?mode=json
- https://nginx.org/en/download.html
- https://nodejs.org/dist/index.json
- https://peps.python.org/api/python-releases.json
- https://raw.githubusercontent.com/nodejs/Release/main/schedule.json
- https://raw.githubusercontent.com/ruby/www.ruby-lang.org/master/_data/branches.yml
- https://raw.githubusercontent.com/ruby/www.ruby-lang.org/master/_data/releases.yml
- https://raw.githubusercontent.com/rust-lang/rust/master/RELEASES.md
- https://static.rust-lang.org/dist/channel-rust-stable.toml
- https://www.python.org/ftp/python/
Audit Log
Every Organization keeps an audit log: a chronological record of the significant actions taken in that Organization — who did what, to which object, and when. Use it to answer questions like “who deleted the staging Environment?” or “when was this app last scaled, and by whom?”.
What is recorded
Each entry captures:
- Action — one of
create,update,delete,invite,join,leave,deploy,rollback,promote,scale,restart,transfer,publish,unpublish,credentials_read,block,unblock,verify,release. - Who — the ID of the user who performed the action, or
nullwhen the platform acted on its own (transfer blocks, background domain checks, automatic token revokes). - What — the kind of object (
organization,project,environment,resource,group,secret,variable,invitation,domain,deploy_token,git_connection,repo_link) and its ID. - When — a UTC timestamp.
- Details — action-specific context, such as the Resource name, the Environment it lives in, the new replica count for a scale, or a custom domain that was added or removed. A released domain is recorded twice: once for the organization that released it, once for the organization that was serving it. A blocked or unblocked entry’s details always name the reason (currently always the monthly transfer allocation).
Recorded actions include:
| Object | Actions |
|---|---|
| Organization | created, updated, deleted, ownership transferred |
| Project | created, updated, deleted; member capabilities changed; member removed |
| Environment | created, updated, deleted, deployment triggered, promoted from another environment, deployment rolled back |
| Resource | created, updated, deleted, scaled, restarted, deployment triggered, rolled back, database or bucket attached or detached, blocked or unblocked for exceeding the monthly transfer allocation, custom domain added, verified (by you or the background check), released, removed, or expired unverified |
| Object Storage | bucket made public or private, access credentials read, storage-tier move started or abandoned, object deleted |
| Group | created, updated, deleted, member added or removed, project access granted, changed or revoked, per-environment override set or removed |
| Secret | created, updated, deleted — by scope (Organization, Project, or Environment) |
| Variable | Organization or Environment variable set or removed |
| Invitation | sent, resent, cancelled, accepted |
| Deployment token | created, revoked (by a person, or automatically with a reason) |
| GitHub/GitLab connection | connected, disconnected |
| Repository link | linked or changed, unlinked, Deploy latest run |
Secret entries record the secret’s scope and name only — never its value. Variable entries likewise record the variable’s scope and key, not its value.
The log records changes, not reads — viewing a page or listing Resources leaves no entry. The one deliberate exception is reading an Object Storage bucket’s access credentials, which is always logged because the credentials grant standing access.
Who can view it
Only the Organization’s Owner and Admins can read the audit log. Regular members — and project guests, who are not Organization members at all — receive a permission error. See Access Control for how these roles work.
Reading the log
The audit log is exposed through the platform API at your Organization’s audit-logs endpoint. You can call it with your CLI’s API token — paas auth token prints it for scripting:
curl -H "Authorization: Bearer $(paas auth token)" \
"https://api.d2o.io/api/v1/organizations/acme/audit-logs"
For self-hosted installations, substitute your API origin. Entries come back newest first:
[
{
"id": "8f2c1e0a-6b7d-4a2e-9c31-5d8e0f4a2b91",
"organization_id": "1b9e7d34-2a5f-4c8e-b061-7f3a9c2d5e10",
"user_id": "c4a2f8d1-9e3b-47c6-8a50-2b6d1e9f7c34",
"action": "scale",
"resource_type": "resource",
"resource_id": "5e0d3b7a-1f9c-4826-a4d2-8c7b5a1e9f03",
"details": {
"name": "web",
"replicas": 3,
"environment": "production"
},
"created_at": "2026-08-30T14:12:07.412391Z"
},
{
"id": "0a7b5c3d-8e1f-42a9-b6c4-3d9e2f8a1b05",
"organization_id": "1b9e7d34-2a5f-4c8e-b061-7f3a9c2d5e10",
"user_id": "c4a2f8d1-9e3b-47c6-8a50-2b6d1e9f7c34",
"action": "create",
"resource_type": "resource",
"resource_id": "5e0d3b7a-1f9c-4826-a4d2-8c7b5a1e9f03",
"details": {
"name": "web",
"type": "WebApp",
"environment": "production"
},
"created_at": "2026-08-28T09:44:51.208763Z"
}
]
Paging
Two query parameters control paging:
limit— entries per page. Defaults to 50; capped at 100.offset— how many entries to skip. Defaults to 0.
curl -H "Authorization: Bearer $(paas auth token)" \
"https://api.d2o.io/api/v1/organizations/acme/audit-logs?limit=100&offset=100"
Next steps
- Access Control — the roles that gate audit log access
- Managing Your Organization — members, roles, and ownership
- CLI Overview & Authentication — logging in and API tokens
Troubleshooting & FAQ
Symptom-first answers to the problems people hit most often. Each entry explains why it happens and what to do about it. The examples use an Organization acme, Project shop, Environment production, and an app named web.
My build is stuck or failed
Why it happens. Every push to your app’s repository starts a build. While it runs, the deployment shows as Building; if the buildpack cannot build your code, it moves to Failed and the entry carries the build’s error message. A build that finishes but does not ship is not stuck — if the Environment’s Automatic deployment setting is off, the build stops at Built (“Built. Waiting to be deployed (auto-deploy is off)”) until you deploy it yourself.
What to do. Check the deployment history — in the dashboard, the app’s Deployments tab; from the CLI:
paas releases -o acme -p shop -e production
-
Failed — the REASON column of
paas releases(andpaas deploys info <N>for the full timeline) gives the failure message;paas ps,paas watchand the log stream report it too.paas logs --build <N> -a webprints that deployment’s saved build log (kept for the newest 1000 deployments in the Environment). The message says which kind of failure it was:-
A problem in your app. The message names the build step that failed and, where it can, the error — for example:
The build step failed: failed to build: exit status 1. Fix the error in your app and push again.Two others point at the repository itself: “The repository has no code yet. Push a commit and the build starts automatically.” and “The branch to build wasn’t found in the repository. Push it, or check the app’s branch setting.”
Fix the code and push again; a new push starts a fresh build. When the message names the step but no error, the usual cause is an app that doesn’t match its framework — check it builds locally with that framework’s standard commands. Supported Frameworks lists what each framework expects, and Builds can’t reach a private address covers dependencies that fail to download.
-
A problem on the platform’s side. The message reads:
The build could not complete because of a problem on our side, not in your app. Push again to retry; if it keeps happening, contact support.Your code is not at fault. Retry by pushing again — an empty commit is enough:
git commit --allow-empty -m "Retry build" git push paas mainIf it keeps happening, contact support.
-
-
Built (“Waiting to be deployed (auto-deploy is off)”) — the build succeeded and is waiting for you. Ship it:
paas deploy -o acme -p shop -e production -a web -
Building for a long time — builds run from source and can legitimately take a while. While no instance is running yet, the app’s instance list also reports the build state and any message:
paas ps -o acme -p shop -e production -a webThe platform doesn’t let a build hang forever. A build that hasn’t started 30 minutes after the push fails with “The build didn’t start. Push a new commit, and contact support if it keeps happening.”; one that disappears mid-build fails with “This build stopped and won’t finish. Push again to start a new one.”; and one still running after 3 hours is marked failed with “This build took over 3 hours without finishing, so it was marked failed. If it does finish, it will still be deployed; otherwise push again.” The Organization’s owner is also emailed about a recent push whose build stalls this way.
Builds can’t reach a private address
Why it happens. Builds can reach the public internet but not private network addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10) or the platform’s internal services. A build step that fetches from one — a private package mirror, a git dependency on an internal host — fails with a connection timeout or “could not resolve” error.
What to do. Serve the dependency from somewhere reachable over the internet (a public registry, or a private registry with a public address and credentials), or vendor it into your repository. See How Builds & Deployments Work.
Logs say the image is still building
Why it happens. Right after a push there is nothing to show logs from: your new version has no running instance yet. Instead of a bare “not found” error, the log stream reports where things stand:
- the image is still building,
- the build failed (with the build’s error message),
- the build hasn’t started yet, or the build finished but no instance is running yet — either it hasn’t been deployed (Automatic deployment is off) or the rollout is still in progress.
What to do. Match the message:
- Still building — wait, then retry
paas logs -a web --tail(or the dashboard’s Logs tab). - Build failed — see the entry above: the message says whether to fix your code or retry.
- Built but no instance running yet — if the Environment’s Automatic deployment is off, run
paas deploy -a webto ship the build; otherwise the rollout is in progress — watch it withpaas status -o acme -p shop -e production -a web.
Once the first instance is running, logs flow normally. See Logs for the full log tooling.
My deployment failed or was rolled back
Why it happens. A probed instance must pass its health check before taking traffic. If the rollout makes no progress toward healthy new instances for 5 minutes, the deployment fails, and with automatic rollback on (the default) the previous version is put back. The deployment’s reason says what went wrong:
- “Your app keeps exiting (exit code N)”, with its last output — the app crashes on start. Read the output, fix the code, and push again.
- “Health check /health returned 404”, or “… timed out” — make the health check path return a success response, quickly and without login.
- “Health check /health couldn’t connect (is your app listening on $PORT?)” — bind your server to the port in
$PORT. - “A required setting or secret is missing” — check the container configuration and secret bindings. A missing app secret may instead leave its variable absent; validate required settings at startup.
- “The new version didn’t become healthy within 5 minutes: …” — the detail after the colon says why.
- “There wasn’t enough free capacity on our servers to start the new version” — a problem on the platform’s side, not in your app; deploy again later.
What to do. Fix what the reason names and push again. See Deployments, Health Checks & Rollback.
My app is not seeing a new secret
Why it happens. Secret values are injected into an instance when it starts. Linking a variable to a secret redeploys the app, and changing a secret’s value restarts every linked app within seconds — but creating a secret does not. If the app was already running with a variable linked to a secret that did not exist yet (you deleted and re-created the secret), its instances keep running without the value until they are replaced.
What to do. Restart the app so fresh instances come up with the secret in place:
paas restart -o acme -p shop -e production -a web
Or click Restart on the app’s page in the dashboard. The restart is rolling, so a multi-instance app keeps serving traffic. (A singleton app stops its one instance before starting the new one, so expect a few seconds of downtime.) Confirm the binding itself is correct on the app’s environment variables (a variable referencing the secret) — see Managing Secrets and Environment Variables.
I can’t scale — autoscaling is on
Why it happens. While autoscaling is enabled, the autoscaler owns the replica count; a fixed value you set would immediately be fought over. The platform therefore rejects paas scale with a conflict error while autoscaling is on, and the dashboard replaces its replica controls with a note that the count is managed by the autoscaler.
What to do. Turn autoscaling off — Disable on the app’s page in the dashboard, or paas apps update web --autoscaling false -o acme -p shop -e production — then either re-enable it with a new range (min/max replicas and target CPU) or set a fixed count:
paas scale 3 -o acme -p shop -e production -a web
The same happens for a singleton app, with its own message: turn singleton off first (paas apps update web --singleton false -o acme -p shop -e production), or, if d2o-paas.yaml sets singleton: true, change it there.
I got a “limit exceeded” error
Why it happens. Your Organization’s plan sets limits on what it can hold, and every write that would consume capacity — creating a resource, scaling up, adding an Environment — is checked against them. A request over the limit is rejected with a LIMIT_EXCEEDED error that names the limit, your plan’s maximum, and your current usage.
What to do. Check your Organization’s effective limits and current usage in the dashboard (any member can see them). Free capacity by removing things you no longer need, or talk to whoever operates your platform about your Organization’s plan. Enforcement only applies to new writes: nothing you already have is ever deleted because of a limit. See Plans, Limits & Usage.
My app scaled to zero, or my bucket stopped serving publicly
Why it happens. Separately from the capacity limits above, your Organization also has a monthly transfer allocation — a budget for the public network traffic your Web Applications actually send and receive each month. If usage for your Organization, a Project, an Environment, or a single Resource stays over that budget, the platform automatically scales every Web Application and Background Worker covered by it to zero replicas, and switches every public Object Storage bucket covered by it to private. An affected app or worker shows status Blocked instead of Stopped, and its Restart button is disabled with the reason underneath (over the monthly transfer allocation — starts again automatically when usage is back under budget, normally at the start of next month — plus a link to your usage). An affected bucket’s Visibility shows Private — blocked instead of Public, its public URL is hidden (it wouldn’t work while blocked), and a note underneath explains why and that it’ll be public again once unblocked. The paas apps/buckets list and info commands show the same thing in their status text.
What to do. The Organization’s owner gets an email when this happens (“Resources blocked: over monthly transfer budget”) and another once it’s lifted. If you have Owner or Admin access, the audit log records a block entry naming the resource. The block clears itself automatically once usage is back under budget — normally when the next calendar month starts — or sooner if whoever operates your platform installation raises the allocation. There is nothing to scale, restart, or reconfigure to force it back on early. See Monthly transfer allocation for what counts and the alert thresholds leading up to it.
I’m getting “429 Too Many Requests”
Why it happens. The platform’s API limits how fast each client can send requests. A script or CI job that loops over many apps, or retries in a tight loop, can go over. The response is 429 with a Retry-After header giving the seconds to wait.
What to do. Wait the Retry-After time, then continue more slowly — add a delay between requests, and back off on repeated 429s rather than retrying immediately. See API rate limits for the numbers.
I can’t sign in, or my invitation isn’t working
Why it happens. The platform is invitation-only. Signing in does not create an account by itself — you need a pending invitation for your email address. When someone invites you:
- New to the platform — you receive an email asking you to set a password; once set, you land on the invitation accept page.
- Already have an account — you receive an invitation email with a link to the accept page instead (no password reset).
You must be signed in to accept. A Project invitation is also rejected — both when it is created and again when you try to accept it — if your email already belongs to a member of that Organization; members get Project access through groups instead.
What to do.
- No account after signing in? Ask an Organization Owner or Admin to invite your email address, then follow the email’s link.
- Invitation email never arrived, or the link expired? Ask the inviter to click Resend next to your invitation in the Organization’s or Project’s Members settings. Invitation links work for 7 days; a resend sends a fresh one.
- Invited to a Project you can’t accept because you’re already in the Organization? Ask an Owner or Admin to grant your group access to the Project instead — see Groups & Project Permissions.
See Inviting People.
I can’t delete an environment
Why it happens. The Environment is marked Protected. Protection exists exactly for this: a protected Environment cannot be deleted at all, so a slip of the mouse can’t take out production.
What to do. If you really mean to delete it, remove protection in the Environment’s settings first, then delete. Deleting an unprotected Environment permanently removes it and all of its resources, and asks you to type the Environment name to confirm. See Organizations, Projects & Environments.
My app has no URL
Why it happens. Two common causes:
- It’s a Background Worker. Workers run without a public URL by design — they receive no HTTP traffic. Only Web Applications get one.
- It hasn’t deployed yet. The address only answers once a deployment is live — check the app’s Deployments tab, and see Supported Frameworks for the files each framework’s build needs.
What to do. If the code needs to receive HTTP requests, create it as a Web Application. For a Web Application, push (or click Deploy latest on its GitHub/GitLab link), and once the deployment succeeds the public URL answers; it’s shown on the app’s page in the dashboard. To serve it on your own domain instead, see Custom Domains & TLS.
“several apps build from this directory (…) — pass -a”
Inside an Environment’s shared repository the CLI picks the app from your working directory. When more than one app builds from the same directory (for example two apps at the repository root that run different processes), it cannot choose — name the app:
paas logs --tail -a bot
“no app builds from this directory — pass -a”
Inside an Environment’s shared repository, your working directory doesn’t
match any shared app’s source directory, and it isn’t the repository root
either — so the CLI has nothing to infer from. (If the checkout also has a
leftover per-app paas remote from before the shared remote existed, and
that remote points at the same org/project/Environment you’re actually
targeting, the CLI falls back to that remote’s app instead of showing this
error — you’ll only see it when there’s no such fallback. It never falls back
on the ambiguous case above, since guessing among several matches would be
worse than asking.) Name the app:
paas logs --tail -a bot
git push over HTTPS says “Authentication failed”
An HTTPS push token expires after 30 days. Get a fresh one and push again — for the Environment’s shared remote:
paas git remote --https
For an app with its own repository, add -a APP. In the dashboard, use
Regenerate token on the Git remote panel. Pushing over SSH avoids this
entirely.
git push says “This environment deploys from GitHub …”
This environment deploys from GitHub acme/shop@main. Push there instead, or allow direct pushes in the environment's settings.
(An app-linked target says “This app deploys from … in the app’s settings.”) The Environment or app deploys from a linked GitHub or GitLab branch, and direct pushes are off. Push to the linked branch instead, or turn on Allow direct pushes on the repo link (Environment or app settings). See Deploy from GitHub or GitLab.
Next steps
- Logs — the first place to look when an app misbehaves
- Deployments, Health Checks & Rollback — deployment states and manual deployments
- Plans, Limits & Usage — how capacity is counted
- Inviting People — the full invitation flow