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
- Open the app (Stacks, then the stack, then the app) and its Environment tab.
- In Key-Value mode, enter the key and the value. Tick Build time if the value is only needed while building the image.
- 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.
$ 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 agoFrom 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.
$ 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":"*****"}}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):
$ docker exec <container> env | grep -E '^(LOG_LEVEL|REGION|GREETING_BUILD)='
REGION=eu-central
LOG_LEVEL=info
$ docker exec <container> cat /build-arg.txt
helloBulk 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/bulkwith anentriesarray: 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.
$ 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).
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 withoutAPP_KEYgets 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.