Docs / Core Concepts / Environment Variables

Environment Variables

Environment variables configure your apps without putting secrets in the code. They are set per app or shared by a whole stack, stored encrypted, and injected into the container at the next deployment.

Adding Variables

A key uses uppercase letters, digits and underscores, and starts with a letter or an underscore (DATABASE_URL, _INTERNAL). A lowercase key is refused with 422.

From the Dashboard

  1. Open the app (Stacks, then the stack, then the app) and its Environment tab.
  2. In Key-Value mode, enter the key and the value. Tick Build time if the value is only needed while building the image.
  3. Click Add Variable. Values are shown masked; Reveal displays them and needs at least the Developer role on the app.

From the CLI

sh0 env <app> set takes a KEY=VALUE pair and --build for a build-time variable; list never prints values.

Terminal
$ sh0 env my-api set LOG_LEVEL=info
[ok] Set LOG_LEVEL for 'my-api'
$ sh0 env my-api set GREETING_BUILD=hello --build
[ok] Set GREETING_BUILD for 'my-api'
$ sh0 env my-api list
  KEY             VALUE    BUILD  CREATED
  --------------  -------  -----  -------
  LOG_LEVEL       *******  no     0s ago
  GREETING_BUILD  *******  yes    0s ago

From the API

The routes are /api/v1/apps/:id/env-vars (GET, POST) and /api/v1/apps/:id/env-vars/:env_id (PATCH, DELETE). Responses always mask the value; add ?reveal=true to the GET to read it.

Terminal
$ curl -s -X POST https://panel.example.com/api/v1/apps/e17d4314-ff61-485a-a509-58b3974c1eee/env-vars \
    -H "Authorization: Bearer $SH0_TOKEN" -H "Content-Type: application/json" \
    -d '{"key":"DATABASE_URL","value":"postgres://app:[email protected]:5432/appdb"}'
{"data":{"app_id":"e17d4314-ff61-485a-a509-58b3974c1eee","created_at":"2026-10-02T02:49:26.463374291+00:00","id":"68ddf6a1-d9e0-49e4-9893-c320eaa58b62","is_build":false,"key":"DATABASE_URL","value":"*****"}}

$ curl -s -X PATCH https://panel.example.com/api/v1/apps/e17d4314-ff61-485a-a509-58b3974c1eee/env-vars/68ddf6a1-d9e0-49e4-9893-c320eaa58b62 \
    -H "Authorization: Bearer $SH0_TOKEN" -H "Content-Type: application/json" \
    -d '{"value":"postgres://app:[email protected]:5432/appdb"}'
{"data":{"app_id":"e17d4314-ff61-485a-a509-58b3974c1eee","created_at":"2026-10-02 02:49:26","id":"68ddf6a1-d9e0-49e4-9893-c320eaa58b62","is_build":false,"key":"DATABASE_URL","value":"*****"}}
Warning
Adding a key that already exists does not update it: the API answers 201 and the app then holds two entries with the same key. To change a value, edit the existing variable (PATCH, or Edit in the dashboard).

When Changes Apply

Saving a variable does not restart anything. The running container keeps its old environment until the next deployment, from the dashboard or with sh0 deploy <app>.

Build-time variables are passed to docker build as --build-arg; the Dockerfile must declare them with ARG. They are absent from the running container. For an app deployed by archive upload (sh0 push), a redeploy reuses the last image: runtime variables change, but a new build-time value only takes effect at the next sh0 push.

Observed on a test app after a redeployment (the build-time GREETING_BUILD reached the image, not the environment):

Terminal
$ docker exec <container> env | grep -E '^(LOG_LEVEL|REGION|GREETING_BUILD)='
REGION=eu-central
LOG_LEVEL=info
$ docker exec <container> cat /build-arg.txt
hello

Bulk Edit (.env)

The Environment tab accepts KEY=VALUE lines, one per line; comments (#) and empty lines are ignored. The two ways behave differently:

  • Paste .env then Parse & Add adds each line as a new variable and leaves the others untouched (an existing key gets a duplicate, see above).
  • The .env Editor then Save All replaces every variable of the app with the content of the editor. The API equivalent is PUT /api/v1/apps/:id/env-vars/bulk with an entries array: any key absent from the array is deleted.

Stack-Level Variables

A variable set on a stack is read by every app of that stack. Open the stack and use Shared variables in the Internal network & shared settings panel, or the API /api/v1/projects/:id/env-vars (a stack is a project in the API). Posting an existing key updates it. Stack values are never returned, not even to an admin.

Terminal
$ curl -s -X POST https://panel.example.com/api/v1/projects/5aee8eea-9f77-4d10-ac21-2fe4a565e666/env-vars \
    -H "Authorization: Bearer $SH0_TOKEN" -H "Content-Type: application/json" \
    -d '{"key":"REGION","value":"eu-central"}'
{"data":{"created_at":"2026-10-02 02:49:26","id":"e89e5915-e5dd-4878-b1b6-b6df150df54d","is_build":false,"key":"REGION","project_id":"5aee8eea-9f77-4d10-ac21-2fe4a565e666","updated_at":"2026-10-02 02:49:26"}}

When the same key exists on both, the app wins, and the deployment log lists the stack variables it overrode. In the run below, the stack sets LOG_LEVEL=debug and REGION=eu-central, and the app sets LOG_LEVEL=info:

Encryption at Rest

Every value is encrypted with AES-256-GCM before it is written to the sh0 database. The key is the instance master key, kept in master.key and protected by master.passphrase (PBKDF2), both in the sh0 data directory (/var/lib/sh0 on a standard Linux install).

Warning
Without master.key and master.passphrase, the stored values (and encrypted backups) cannot be decrypted. Keep a copy of both files off the server.

Connecting to Other Services

sh0 does not expand references: a value such as $OTHER_VAR reaches the container as written. Write the full value instead, using internal hostnames: an app reaches another app of the same stack at <app-name> or <app-name>.internal, and any database server at <server-name>.internal.

What sh0 Does Not Inject

  • No system variables. sh0 adds no SH0_* variable (app name, domain, commit) to your containers. The only exception: a Laravel or Lumen app without APP_KEY gets one generated at its first deployment and saved as an app variable.
  • Variables in cron jobs. A cron job attached to an app receives the app's and the project's runtime variables, in a throwaway container from the app's image: see Cron Jobs.