Environments & Services
Environments
An environment is one deployable unit: its own Dokku app, build, domains,
database, storage mounts, and environment variables, tracked against a
specific git branch. Most projects start with a single environment (e.g.
main).
The CLI examples below use <environment_id>/<project_id> placeholders
(UUIDs) since that’s what the CLI accepts today. The underlying REST API
also accepts a project’s or environment’s slug anywhere it accepts an id
(see the API Reference) - CLI support
for slugs is planned but not shipped yet.
Multi-service environments
A monorepo with, say, a Next.js frontend and a Python backend in separate folders doesn’t need two disconnected top-level environments. Instead, one root environment can have service environments underneath it:
canopy environments create main --project my-app --repository your-org/your-repo# ^ this becomes the root
canopy environments create main \ --parent <root_environment_id> \ --name frontend \ --build-path frontendA service is a full environment - its own build, domains, database,
variables, deploy history - just linked to its parent for grouping. Roots
and services can be deployed, torn down, and rolled back independently of
each other. branch/repository default from the parent when omitted
(services commonly share their parent’s branch), so you usually only need
--parent, --name, and --build-path.
canopy environments list --project my-app<root_environment_id> main └─ <service_environment_id> frontendDeleting a root environment (canopy environments rm <root_id>) tears
down every service under it too - real teardown per environment (Dokku
app destroy, database unlink), not just a database cascade. An environment that holds
volumes or databases asks you to confirm and choose what happens to them first - see
Deleting an environment that holds data.
Environments from an image
An environment can run an existing container image instead of a repository:
canopy environments create budget --project budget --image docker.io/actualbudget/actual-server:25.9.0canopy deploys create --env <environment_id> --image docker.io/actualbudget/actual-server:25.9.1The image is imported into the platform registry by digest, so a deploy always runs exactly the bytes that were
imported and a rollback needs no network access to the source registry. Creating the environment starts its first
deploy. --image cannot be combined with --template or --parent, and needs Forest placement.
canopy deploys create without --image redeploys the current reference; a rollback returns to an earlier
import together with the configuration it ran with.
Deploying an image on every push
Add --repository and the positional becomes the branch whose pushes redeploy the image:
canopy environments create main --project budget --name budget \ --image docker.io/actualbudget/actual-server:25.9.0 --repository giovanni/budgetThe repository holds the environment’s .canopy.yml and nothing to build. Each push to that branch reads the file
at the pushed commit, applies it (volumes, variables, health) and records it as the environment’s configuration,
then deploys. An image: line in the file moves the environment to that image, so bumping the version is a commit.
A push whose file is invalid deploys nothing and shows as “configuration rejected” in the environment’s webhook
deliveries. A repository without the file redeploys the current image and configuration. Pushes follow the same
path filter (build_path) and deploy_trigger setting as any other environment.
An image that runs as root is refused with an explanation: set run_as_user in the environment’s configuration to
a numeric user the image supports.
A private image is pulled with the credentials your team stored for its registry. Store them from a file (never on
the command line): a line username:password, or a JSON object with username and password:
canopy registries add --team <team_id> --host ghcr.io --credential-file ./ghcr-credentialThey are used only by Host while it resolves and copies the image; they are not handed to a Grove and never shown
back. An image that needs credentials and has none fails with registry_auth_required and this command as the fix.
Every image is also queued for a vulnerability scan, and the summary appears on the health card once one has run.
Scans run from a build host, so on a Grove with none the scan job fails and no summary appears yet.
Check before you deploy
canopy check --env <environment_id>Looks at everything that can be known without deploying and changes nothing: the image resolves and runs as a
non-root user, it serves the port the configuration declares, a path the image stores data in has a volume, a
process that mounts a volume is not scaled past one replica, required variables are set, a domain exists, and the
health mode suits the app. Each check is ok, warn, FAIL or skip, with a fix where one applies; the command
exits non-zero when any check fails.
Application configuration
The configuration an environment deploys with (ports, health, processes, volumes, run_as_user) is a .canopy.yml
document. For a repository environment each build records the file at its commit; an environment with no
repository is configured directly:
canopy config apply -f canopy.yml --env <environment_id>canopy config get --env <environment_id>canopy config revisions --env <environment_id>Every change is an immutable revision (an identical document reuses its revision), validated exactly like a
repository file and redeployed unless --no-apply is given. A build or deploy always runs the revision that was
current when it started, so a later edit never changes a deploy that is already running. Do not put secrets in it:
reference environment variable names only.
The document can declare what the app needs, and applying it creates the parts that do not exist yet:
version: 1image: docker.io/actualbudget/actual-server:25.9.0health: { mode: tcp }volumes: data: { mount: /data, size: 10 }env: required: [ACTUAL_PASSWORD] defaults: { ACTUAL_PORT: "5006" }processes: web: { command: "node app.js --port 5006", expose: true, port: 5006 } worker: { command: ["node", "worker.js"] }cron: - { name: nightly, schedule: "0 3 * * *", command: "node cleanup.js" }command is a string (split like a shell, quotes respected) or an argument list; args are appended after the
image’s entrypoint. A declared volume is created if missing and never resized by a later document (a different
size is refused with the reason); env.defaults are set only when the variable does not exist yet.
processes.<name>.expose: true routes the environment’s domains to that process instead of web. A conflict
between the document and what already exists is refused with 409 rather than half-applied.
Scheduled commands show their next run and last outcome:
canopy jobs list --env <environment_id> # cron entriescanopy jobs list --env <environment_id> --kind run # recorded one-off runsHealth checks
An app that declares no probes is health-checked according to its health mode:
| Mode | Meaning |
|---|---|
auto (image environments) / tcp | The port must accept connections; any HTTP answer below 500 counts as serving. An image with no health endpoint deploys. |
http | The verification path (/ by default) must answer 2xx or 3xx. |
legacy_root_2xx (repository environments) | Same as http, kept as the default so existing apps behave as before. |
canopy environments update <environment_id> --health-mode tcpA probe declared in .canopy.yml always wins over the mode.
Persistent volumes
An environment running on Forest keeps data across deploys and restarts in a volume: named storage mounted at a path inside one of its processes.
canopy volumes create data --mount /data --size-gb 10 --env <environment_id>canopy volumes list --env <environment_id>Creating a volume restarts the app once with the volume mounted (--no-apply records it for the next deploy).
Sizes are 10 to 10240 GB. A volume cannot be resized after creation: back up and restore into a larger one.
Removing a volume never deletes its data:
canopy volumes detach <volume> --env <id>stops mounting it and keeps the data for the volume’s retention period (7 days unless--retention-dayswas set at creation).canopy volumes attach <volume> --env <id>mounts a detached volume again while it is still kept.canopy volumes purge <volume> --env <id> --confirm <volume name>permanently deletes a detached volume. This cannot be undone, and it is refused for a volume that is still mounted.
Destroying an environment keeps its volumes for their retention period too (see below), and a kept volume can be mounted by another environment of the same project:
canopy volumes retained --env <environment_id>canopy volumes adopt <volume_id> --mount /data --env <environment_id>The volume keeps its data and its size; --name and --process choose what it is called and which process mounts
it. Only volumes of the same project on the same Grove are offered, and only until their retention window ends.
A volume is exclusive to one replica of one process: asking for more than one replica of a process that mounts a volume is refused, because two copies of the app cannot safely write the same files.
A pod that mounts a volume gets the volume’s group ownership set, so an app running as a non-root user can write to it on any storage class. Where a Grove reports that volumes live on each node’s own disk, a volume larger than the largest node is refused up front, and one that would leave the disks more than 80% requested is created with a warning.
canopy run can work on the same data:
canopy run --env <environment_id> --with-volumes -- ls -la /dataThe command runs in the app’s image with the volumes of the process mounted, beside the process’s running pods (a volume can only be used from the node it lives on). It is refused with a reason when the process has no volumes, a volume is not attached yet, or the process’s volumes are on different nodes.
The /environments/{id}/storage routes and canopy storage commands are for Dokku-hosted environments only and
refuse a Forest environment.
Backing up and restoring volumes
Backups include volumes; a nightly sweep covers every environment that has a live volume. Each volume’s backup is either stopped (the default: the processes that mount it are scaled to zero while it is archived, then restored, so the copy is consistent) or online (archived while the app runs, and labelled fuzzy):
canopy environments update <environment_id> --backup-consistency onlinecanopy backups create --env <environment_id>canopy backups restore --backup <backup_id> --components volumes,variables --confirm <slug> --env <environment_id>Stopped backups are a deliberate, brief outage. If a run is interrupted the processes are started again automatically within two hours.
To have a backup taken automatically before a deploy that changes the image:
canopy environments update <environment_id> --predeploy-backup trueIt is best effort: a failed backup never blocks the deploy, but is recorded as an event.
A restore first takes a snapshot of the current state, stops the app, verifies the archive’s checksum before touching the volume, extracts into a staging directory and swaps it in, keeps the previous contents aside until the next restore, recreates a volume that no longer exists, starts the app again and waits for it to become healthy. Components that do not apply to the environment (for example a database on a Forest environment) are skipped rather than failing the restore.
Deleting an environment that holds data
An environment that holds volumes or databases is not deleted until you confirm and choose what happens to them:
canopy environments rm <environment_id> --plancanopy environments rm <environment_id> --confirm <slug> --volumes retain --databases delete--confirmmust be the environment’s slug.--volumes retain(the default) keeps each volume’s data for its retention period, visible under the project and available to adopt;deleteschedules it for deletion.--databases retain|deletehas no default. Retaining is available for Dokku databases; a Forest database cannot outlive its environment yet, sodeleteis the only choice for one.
Without them the request is refused and the response lists what would be affected. An environment with no volumes or databases needs none of this.
Importing a Docker Compose file
canopy compose import docker-compose.yml --project my-app # print the plancanopy compose import docker-compose.yml --project my-app --apply # create the environmentsEach service that uses an image becomes an image environment named after the service, so services reach each other
by name as they do in Compose. The port it publishes or exposes is routed, named volumes become volumes (a volume
means one replica), and environment variables become configuration defaults, except names that look like secrets
(PASSWORD, TOKEN, KEY, DSN, …), which are set as secret variables and never printed or written to
configuration. Services are created in depends_on order.
Anything with no equivalent is listed rather than dropped silently: build (only images are imported), bind mounts
(host paths do not exist on Forest: use a named volume and copy the data in), networks, healthcheck (the port is
checked instead), entrypoint, env_file and the like. With --apply each service is deployed once when created and
redeployed after its volumes, variables and configuration are recorded, so its first deploy may fail if it needs a
secret; the final one has everything.
Seeing how an app is doing
canopy state --env <environment_id>One page instead of five commands: the rollout phase, the image and its vulnerability summary, each process’s
ready count and restarts (with why the last one exited, for example OOMKilled), volumes and the node each is on,
routes and certificate state, the last successful backup, failing scheduled commands, and a list of everything that
needs attention. The dashboard shows the same on the environment’s Resources tab, with a button to run the
deployability check.
Why a deploy is slow or stuck
canopy pods --env <environment_id>Lists every process’s pods: the state, how long it has been in that state, the node, restarts, whether the pod is on the current or the previous revision, and Kubernetes’ own words for why any pod is not ready.
web: 1/1 ready POD STATE FOR NODE RESTARTS REVISION web-new-aaaaa Unschedulable 20m - 0 current Unschedulable: 0/3 nodes are available: 2 Insufficient cpu. web-old-bbbbb Ready 2h 5m worker-3 0 previousA new pod that cannot start does not take your app down: the previous pod keeps serving until the new one is ready.
canopy state reports it under problems once it has waited two minutes (an image that cannot be pulled or a pod
that keeps crashing is reported at once), and the build page shows the same reason on the “Provisioning pod” and
“Waiting for pod readiness” steps. The common causes are no free CPU or memory on the Grove, an image that cannot be
pulled, a volume that cannot be attached, and a readiness probe that never passes.
Managed databases
canopy services create db --engine postgres --size-gb 20 --env <environment_id>canopy services list --env <environment_id>You choose the engine and size; the operator that runs it, its storage class and its credential Secrets are a
platform decision and are not part of the API. A Grove can carry a default backup target, so every managed Postgres
on it is backed up unless it has its own; staff set it with PUT /admin/groves/{id}/default-backup-target. The
credentials Secret it names must exist in each database’s namespace; creating it is still a manual, per-Grove step.
A restore creates a new instance from a backup; to land at an exact moment instead of the end of the backup, add a target time (it must be after the backup finished, not in the future, and the WAL archive must reach it):
canopy services restore <service_id> --backup <backup_id> --name db-restored --env <environment_id> --target-time 2026-09-24T10:30:00ZTemplates on Forest
A multi-service template whose services are all plain images (no repository builds, databases or readiness
commands) is deployed on Forest, when the team is placed there, as sibling environments under one root, tier by
tier in depends_on order. A service reaches an earlier one by its name, and ${services.<key>.host} and
${services.<key>.env.<VAR>} resolve as before. A service that does not converge is marked failed without
stopping its siblings. Templates that build a repository, provision a database or use a readiness command still use
the Dokku path.
Domains
canopy domains add myapp.example.com --env <environment_id>canopy domains list --env <environment_id>Each environment (root or service) gets its own domain(s) and its own TLS
certificate, provisioned automatically after the first successful deploy
following DNS resolving to the host - no separate step needed. If a
certificate hasn’t shown up after a deploy (e.g. DNS wasn’t propagated
yet), force a retry with canopy domains ssl-enable --env <environment_id>
rather than waiting for the next deploy.
Variables
canopy variables set NAME value --env <environment_id>canopy variables list --env <environment_id> --reveal # values hidden by defaultcanopy variables rm NAME --env <environment_id>Values are encrypted at rest. variables set is a real upsert - it
updates an existing name in place rather than erroring on a duplicate.
Build-time vars (like a Next.js app’s NEXT_PUBLIC_* variables) need a
fresh deploy to take effect - setting the variable alone doesn’t
retroactively change an already-built image. Trigger one with
canopy deploys create --env <environment_id>.
From the dashboard, an environment’s Variables tab also has a Copy from another environment action - it copies every non-system variable from a sibling environment you pick, overwriting any variable that already exists under the same name (safe to run more than once). There’s no CLI equivalent yet; see the Dashboard Guide for the click-by-click version, including storage mounts, which aren’t covered on this page.