diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx index 8821faec68..775e57e7ce 100644 --- a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx @@ -70,6 +70,92 @@ printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt docker compose up -d --no-deps loopover`} /> +
+ The hardened default above — .env plus Docker Compose secrets: —
+ has no rotation, audit trail, or RBAC. If you want secrets-manager-grade rotation, audit
+ logging, and access control on top of that default, you can opt into{" "}
+
+ Infisical
+ {" "}
+ — an open-source, self-hostable secrets manager. This is{" "}
+ strictly optional and additive: skip this section entirely and the hardened{" "}
+ .env/Docker secrets default keeps working unchanged.
+
infisical run -- <command> wrapper, which injects secrets as real process
+ environment variables at container launch. Nothing under src/ knows or cares
+ whether a given env.SOMETHING value came from Infisical, .env, or
+ a Docker secret file.
+ infisical login once,
+ then infisical init from the repo root to link a local{" "}
+ .infisical.json to an Infisical project.
+ prod) matching how you think
+ about this deployment, and add the secrets you want Infisical to manage — same variable
+ names your .env/docker-compose.yml already use (
+ GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, provider API
+ keys, and so on).
+
+ With the flag unset (the default), neither script touches Infisical at all — not even a
+ presence check — so an operator who has never heard of Infisical is completely unaffected.
+ With it set, the restart step (the one that actually launches the container) runs through{" "}
+ infisical run --; a missing infisical binary fails the deploy
+ immediately with a clear error rather than silently deploying without the secrets you asked
+ for.
+
infisical run -- injects secrets into its own child process's environment — in
+ this case, the docker compose up invocation. Docker Compose only lets a host
+ shell variable reach the container for an environment: entry written as{" "}
+ {`SOMEVAR: "\${SOMEVAR}"`}. It does not reach a plain{" "}
+ env_file: .env block, which reads that file's literal contents at container
+ runtime and is never affected by the deploying shell's environment. The GitHub App private
+ key, webhook secret, API/MCP tokens, and the rest of the native-secrets list above are wired
+ through the _FILE convention and env_file: .env, not through{" "}
+ environment: interpolation — an Infisical value for one of those exact names,
+ by itself, will not reach the container today. Infisical is the right fit
+ for other variables you reference via {`"\${VAR}"`} interpolation in
+ your own docker-compose.override.yml (a provider API key you add yourself, for
+ example) — not a drop-in override for the pre-wired native-secrets list.
+
+ The safest rule of thumb: for any given variable, pick one source — Infisical or a
+ plain .env/Docker secret file, never both for the same name. Setting the same
+ name in both places doesn't error; whichever mechanism the container actually reads for that
+ variable (see the callout above) wins silently, which is easy to misdiagnose later.
+
Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in
diff --git a/scripts/deploy-selfhost-image.sh b/scripts/deploy-selfhost-image.sh
index 7c5e552737..acac51ca3c 100755
--- a/scripts/deploy-selfhost-image.sh
+++ b/scripts/deploy-selfhost-image.sh
@@ -12,6 +12,9 @@
#
# The image itself carries official release metadata. Set SENTRY_RELEASE only for custom images whose
# source maps were uploaded under that exact id.
+#
+# Optional Infisical secrets (#5120), see docs:
+# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-image.sh
set -euo pipefail
ENV_FILE="${SELFHOST_ENV_FILE:-.env}"
@@ -115,7 +118,7 @@ echo "selfhost image deploy: pulling $IMAGE"
docker compose "${compose_args[@]}" pull --policy always "$SERVICE"
echo "selfhost image deploy: restarting $SERVICE"
-docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE"
+maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE"
wait_for_healthy
env_put LOOPOVER_IMAGE "$IMAGE"
diff --git a/scripts/deploy-selfhost-prebuilt.sh b/scripts/deploy-selfhost-prebuilt.sh
index a198bbb3e4..92c42c1e2a 100755
--- a/scripts/deploy-selfhost-prebuilt.sh
+++ b/scripts/deploy-selfhost-prebuilt.sh
@@ -10,6 +10,7 @@
# SENTRY_RELEASE=gittensory-selfhost@edge-abc123 ./scripts/deploy-selfhost-prebuilt.sh
# SELFHOST_COMPOSE_FILES="docker-compose.yml docker-compose.override.yml" ./scripts/deploy-selfhost-prebuilt.sh
# SELFHOST_SKIP_SENTRY_UPLOAD=1 ./scripts/deploy-selfhost-prebuilt.sh
+# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-prebuilt.sh # opt-in Infisical secrets (#5120), see docs
set -euo pipefail
ENV_FILE="${SELFHOST_ENV_FILE:-.env}"
@@ -110,7 +111,7 @@ YAML
docker compose "${compose_args[@]}" build "$SERVICE"
echo "selfhost deploy: restarting $SERVICE"
- docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE"
+ maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE"
}
require_cmd docker
diff --git a/scripts/lib/selfhost-deploy-common.sh b/scripts/lib/selfhost-deploy-common.sh
index 3a9f25ec38..2b663c11fe 100644
--- a/scripts/lib/selfhost-deploy-common.sh
+++ b/scripts/lib/selfhost-deploy-common.sh
@@ -79,6 +79,27 @@ env_put() {
rm -f "$tmp"
}
+# Optional Infisical wrapper (#5120): when SELFHOST_USE_INFISICAL=1 (opt-in, off by default), prefixes the
+# given command with `infisical run --` so Infisical-sourced secrets are injected as real process env vars at
+# launch -- Infisical's own intended integration shape, requiring zero changes to how src/ reads env.SOMETHING.
+# Strictly additive: with the flag unset/0, this is a transparent passthrough and the existing .env/Docker
+# Compose secrets: path is completely unaffected.
+#
+# `infisical run --` only injects vars into ITS OWN child process's environment, so this must wrap the compose
+# `up` invocation directly (the container's actual process launch), not some earlier step -- a var it injects
+# is visible to `docker compose up` for interpolating `${VAR}` in docker-compose.yml's own `environment:`
+# blocks, but NOT to a blanket `env_file: .env` passthrough (that reads the FILE's literal contents at
+# container-runtime, unaffected by the calling shell's environment). See the self-hosting docs for which
+# variables can actually be Infisical-sourced today given that distinction.
+maybe_infisical_run() {
+ if [ "${SELFHOST_USE_INFISICAL:-0}" = "1" ]; then
+ require_cmd infisical
+ infisical run -- "$@"
+ else
+ "$@"
+ fi
+}
+
compose_file_args() {
local files=()
local file
diff --git a/test/unit/selfhost-deploy-common.test.ts b/test/unit/selfhost-deploy-common.test.ts
new file mode 100644
index 0000000000..ef2b800742
--- /dev/null
+++ b/test/unit/selfhost-deploy-common.test.ts
@@ -0,0 +1,117 @@
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { spawnSync } from "node:child_process";
+import { describe, expect, it } from "vitest";
+
+const libPath = resolve("scripts/lib/selfhost-deploy-common.sh");
+
+function readOptional(path: string): string {
+ try {
+ return readFileSync(path, "utf8");
+ } catch {
+ return "";
+ }
+}
+
+function createHarness() {
+ const dir = mkdtempSync(join(tmpdir(), "gittensory-selfhost-deploy-common-"));
+ const binDir = join(dir, "bin");
+ const infisicalLog = join(dir, "infisical-calls.log");
+ mkdirSync(binDir);
+
+ // Wraps a plain `echo` so a test can tell whether the wrapped command actually ran (its own stdout) and,
+ // separately, whether it ran directly or via the fake infisical binary below (that binary's own log).
+ const wrapperPath = join(dir, "wrapper.sh");
+ writeFileSync(
+ wrapperPath,
+ `#!/usr/bin/env bash
+set -euo pipefail
+. "${libPath.replace(/\\/g, "/")}"
+maybe_infisical_run echo actual-command-ran
+`,
+ );
+ chmodSync(wrapperPath, 0o755);
+
+ function writeFakeInfisical() {
+ writeFileSync(
+ join(binDir, "infisical"),
+ `#!/usr/bin/env bash
+printf '%s\\n' "$*" >> "${infisicalLog.replace(/\\/g, "/")}"
+if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then
+ shift 2
+ exec "$@"
+fi
+exit 1
+`,
+ );
+ chmodSync(join(binDir, "infisical"), 0o755);
+ }
+
+ return {
+ dir,
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
+ readInfisicalCalls: () => readOptional(infisicalLog),
+ writeFakeInfisical,
+ run(env: Record