Stop handing StackPilot's own settings to the stacks it deploys (0.66.3)
CI / check (push) Successful in 13m48s
CI / build-and-push (push) Successful in 4m5s

docker compose ran with StackPilot's whole environment, and compose
resolves ${VAR} from the calling environment before a stack's own .env.
A stack whose compose file says SECRET_KEY: ${SECRET_KEY} therefore ran
with StackPilot's SECRET_KEY instead of the one in its .env — and with
its own again whenever it was started from a shell. AnsiblePilot hit
exactly this: credentials saved under one key became unreadable under
the other, twice.

config.child_env() is os.environ minus every StackPilot setting
(SECRET_KEY, DATA_DIR, STACKS_DIR, PORT, ...); the docker CLI and git
subprocesses now get that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-19 13:49:52 +02:00
1 parent 34458c702c
commit 052a85067b
7 files changed
+44 -7

No files matched your search

+6
View File
@@ -958,6 +958,12 @@ credentials in the database. Leave it unset and one is generated and written to
credentials survive restarts — that file is then part of your backup. Setting it
explicitly always wins and nothing is written.
None of these settings reach the stacks StackPilot deploys. Compose resolves
`${VAR}` from the calling environment before a stack's own `.env`, so until
0.66.3 a stack that uses `${SECRET_KEY}` (or `PORT`, `DATA_DIR`, …) ran with
StackPilot's value instead of its own — and with its own again when started from
a shell. Anything such a stack encrypted in between was keyed to the wrong one.
The host path for stacks is set via `STACKS_HOST_DIR` in `.env`, and it should
be **the same path as `STACKS_DIR`** (`/opt/stacks` by default). Compose runs
inside the backend container, so a stack's relative bind mounts (`./config`) are
+15
View File
@@ -118,3 +118,18 @@ def get_settings() -> Settings:
settings = get_settings()
def child_env() -> dict[str, str]:
"""os.environ without StackPilot's own settings, for docker/git subprocesses.
Compose resolves ``${VAR}`` from the calling environment *before* the
stack's own ``.env``, so everything StackPilot was configured with leaked
into every stack it deployed. A stack whose compose file says
``SECRET_KEY: ${SECRET_KEY}`` silently ran with StackPilot's key instead of
its own — and flipped back whenever it was started from a shell, orphaning
whatever it had encrypted in between. Names like SECRET_KEY, PORT or
DATA_DIR are far too common to share.
"""
own = {name.upper() for name in Settings.model_fields}
return {k: v for k, v in os.environ.items() if k.upper() not in own}
+2 -2
View File
@@ -39,7 +39,7 @@ from typing import Optional
from sqlmodel import Session
from config import settings
from config import child_env, settings
from models.git_source import GitSource, SyncResult
from services import compose_service, crypto_service, stack_lock_service
@@ -124,7 +124,7 @@ def _auth_env(source: GitSource) -> tuple[dict, Optional[str]]:
with their whole stack.
"""
env = {
**os.environ,
**child_env(),
# No interactive prompting: a private repo without credentials must fail
# fast rather than hang forever waiting on a terminal that is not there.
"GIT_TERMINAL_PROMPT": "0",
+4 -3
View File
@@ -31,7 +31,7 @@ from typing import Optional
import httpx
from sqlmodel import Session, select
from config import settings
from config import child_env, settings
from models.registry import Registry
from services import crypto_service
@@ -186,9 +186,10 @@ def cli_env() -> dict:
Points DOCKER_CONFIG at our generated file rather than writing into
``~/.docker``, so what StackPilot manages stays separate from anything the
image ships or an operator put there by hand.
image ships or an operator put there by hand. StackPilot's own settings are
left out: compose would otherwise interpolate them into the stacks.
"""
return {**os.environ, "DOCKER_CONFIG": docker_config_dir()}
return {**child_env(), "DOCKER_CONFIG": docker_config_dir()}
# --------------------------------------------------------------------------- #
+15
View File
@@ -202,6 +202,21 @@ def test_compose_runs_against_our_config_not_the_home_directory(svc):
assert svc.cli_env()["DOCKER_CONFIG"] == svc.docker_config_dir()
def test_compose_does_not_hand_our_settings_to_the_stacks(svc, monkeypatch):
"""Compose interpolates ${SECRET_KEY} from this environment before the
stack's .env — a stack must never run with StackPilot's own key."""
monkeypatch.setenv("SECRET_KEY", "stackpilots-own-key")
monkeypatch.setenv("data_dir", "/lowercase/still/ours")
monkeypatch.setenv("SOME_UNRELATED_VAR", "kept")
env = svc.cli_env()
assert "SECRET_KEY" not in env
assert "STACKS_DIR" not in env
assert "data_dir" not in env
# Everything else the docker CLI may rely on is still passed through.
assert env["SOME_UNRELATED_VAR"] == "kept"
assert env["PATH"] == os.environ["PATH"]
# --------------------------------------------------------------------------- #
# Through the API
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.66.2"
APP_VERSION = "0.66.3"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.66.2",
"version": "0.66.3",
"type": "module",
"scripts": {
"dev": "vite",