Skip to content

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 frontend

A 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> frontend

Deleting 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.0
canopy deploys create --env <environment_id> --image docker.io/actualbudget/actual-server:25.9.1

The 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/budget

The 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-credential

They 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: 1
image: docker.io/actualbudget/actual-server:25.9.0
health: { 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 entries
canopy jobs list --env <environment_id> --kind run # recorded one-off runs

Health checks

An app that declares no probes is health-checked according to its health mode:

ModeMeaning
auto (image environments) / tcpThe port must accept connections; any HTTP answer below 500 counts as serving. An image with no health endpoint deploys.
httpThe 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 tcp

A 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-days was 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 /data

The 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 online
canopy 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 true

It 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> --plan
canopy environments rm <environment_id> --confirm <slug> --volumes retain --databases delete
  • --confirm must 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; delete schedules it for deletion.
  • --databases retain|delete has no default. Retaining is available for Dokku databases; a Forest database cannot outlive its environment yet, so delete is 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 plan
canopy compose import docker-compose.yml --project my-app --apply # create the environments

Each 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 previous

A 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:00Z

Templates 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 default
canopy 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.