Skip to content

Commit 1c4f59e

Browse files
feat(selfhost): optional Infisical secrets management for self-host deploys (#5785)
Adds a maybe_infisical_run helper (scripts/lib/selfhost-deploy-common.sh) that prefixes a command with `infisical run --` when SELFHOST_USE_INFISICAL=1 is set, wiring Infisical's own intended integration shape in at the deploy-script level with zero application code changes. Strictly opt-in: unset (the default), neither deploy script even checks whether the infisical binary exists, so an operator who has never heard of Infisical is completely unaffected. Wired into both deploy-selfhost-image.sh and deploy-selfhost-prebuilt.sh's restart (`docker compose up`) step only -- the actual container process launch, not the image pull/build steps that don't need injected secrets. Documents setup (cloud vs. self-hosted Infisical) and, importantly, the real technical boundary of this integration: infisical run -- only injects vars into its own child process's environment, which Docker Compose only picks up for environment: entries using ${VAR} interpolation -- not for the blanket env_file: .env passthrough the native-secrets list (GitHub App key, webhook secret, API/MCP tokens, etc.) already uses. The docs are explicit that Infisical is additive for other variables, not a drop-in override for those pre-wired secrets. Closes #5120
1 parent e615ec7 commit 1c4f59e

6 files changed

Lines changed: 291 additions & 2 deletions

File tree

apps/loopover-ui/src/routes/docs.self-hosting-security.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,92 @@ printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt
7070
docker compose up -d --no-deps loopover`}
7171
/>
7272

73+
<h2>Optional: Infisical secrets management</h2>
74+
<p>
75+
The hardened default above — <code>.env</code> plus Docker Compose <code>secrets:</code>
76+
has no rotation, audit trail, or RBAC. If you want secrets-manager-grade rotation, audit
77+
logging, and access control on top of that default, you can opt into{" "}
78+
<a href="https://infisical.com" target="_blank" rel="noreferrer">
79+
Infisical
80+
</a>{" "}
81+
— an open-source, self-hostable secrets manager. This is{" "}
82+
<strong>strictly optional and additive</strong>: skip this section entirely and the hardened{" "}
83+
<code>.env</code>/Docker secrets default keeps working unchanged.
84+
</p>
85+
<Callout variant="note" title="No application code changes">
86+
Infisical wires in at the deploy-script level via its own{" "}
87+
<code>infisical run -- &lt;command&gt;</code> wrapper, which injects secrets as real process
88+
environment variables at container launch. Nothing under <code>src/</code> knows or cares
89+
whether a given <code>env.SOMETHING</code> value came from Infisical, <code>.env</code>, or
90+
a Docker secret file.
91+
</Callout>
92+
93+
<h3>Setup: cloud or self-hosted</h3>
94+
<ol>
95+
<li>
96+
Install the{" "}
97+
<a href="https://infisical.com/docs/cli/overview" target="_blank" rel="noreferrer">
98+
Infisical CLI
99+
</a>{" "}
100+
on the machine that runs the deploy script (not inside the app container).
101+
</li>
102+
<li>
103+
Pick where your secrets live: Infisical Cloud (the default, zero infrastructure of your
104+
own) or a self-hosted Infisical instance — if you're already self-hosting LoopOver, you
105+
can self-host Infisical alongside it. Either way, run <code>infisical login</code> once,
106+
then <code>infisical init</code> from the repo root to link a local{" "}
107+
<code>.infisical.json</code> to an Infisical project.
108+
</li>
109+
<li>
110+
Create an environment inside that project (e.g. <code>prod</code>) matching how you think
111+
about this deployment, and add the secrets you want Infisical to manage — same variable
112+
names your <code>.env</code>/<code>docker-compose.yml</code> already use (
113+
<code>GITHUB_APP_PRIVATE_KEY</code>, <code>GITHUB_WEBHOOK_SECRET</code>, provider API
114+
keys, and so on).
115+
</li>
116+
<li>Opt in when deploying:</li>
117+
</ol>
118+
<CodeBlock
119+
filename="shell"
120+
code={`SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-image.sh
121+
# or
122+
SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-prebuilt.sh`}
123+
/>
124+
<p>
125+
With the flag unset (the default), neither script touches Infisical at all — not even a
126+
presence check — so an operator who has never heard of Infisical is completely unaffected.
127+
With it set, the restart step (the one that actually launches the container) runs through{" "}
128+
<code>infisical run --</code>; a missing <code>infisical</code> binary fails the deploy
129+
immediately with a clear error rather than silently deploying without the secrets you asked
130+
for.
131+
</p>
132+
133+
<h3>Interaction with .env and Docker secrets — do not mix the same variable</h3>
134+
<Callout
135+
variant="warn"
136+
title="Infisical only reaches variables interpolated in docker-compose.yml"
137+
>
138+
<code>infisical run --</code> injects secrets into its own child process's environment — in
139+
this case, the <code>docker compose up</code> invocation. Docker Compose only lets a host
140+
shell variable reach the container for an <code>environment:</code> entry written as{" "}
141+
<code>{`SOMEVAR: "\${SOMEVAR}"`}</code>. It does <strong>not</strong> reach a plain{" "}
142+
<code>env_file: .env</code> block, which reads that file's literal contents at container
143+
runtime and is never affected by the deploying shell's environment. The GitHub App private
144+
key, webhook secret, API/MCP tokens, and the rest of the native-secrets list above are wired
145+
through the <code>_FILE</code> convention and <code>env_file: .env</code>, not through{" "}
146+
<code>environment:</code> interpolation — an Infisical value for one of those exact names,
147+
by itself, will <strong>not</strong> reach the container today. Infisical is the right fit
148+
for <em>other</em> variables you reference via <code>{`"\${VAR}"`}</code> interpolation in
149+
your own <code>docker-compose.override.yml</code> (a provider API key you add yourself, for
150+
example) — not a drop-in override for the pre-wired native-secrets list.
151+
</Callout>
152+
<p>
153+
The safest rule of thumb: for any given variable, pick <em>one</em> source — Infisical or a
154+
plain <code>.env</code>/Docker secret file, never both for the same name. Setting the same
155+
name in both places doesn't error; whichever mechanism the container actually reads for that
156+
variable (see the callout above) wins silently, which is easy to misdiagnose later.
157+
</p>
158+
73159
<h2>Private policy</h2>
74160
<p>
75161
Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in

scripts/deploy-selfhost-image.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#
1313
# The image itself carries official release metadata. Set SENTRY_RELEASE only for custom images whose
1414
# source maps were uploaded under that exact id.
15+
#
16+
# Optional Infisical secrets (#5120), see docs:
17+
# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-image.sh
1518
set -euo pipefail
1619

1720
ENV_FILE="${SELFHOST_ENV_FILE:-.env}"
@@ -115,7 +118,7 @@ echo "selfhost image deploy: pulling $IMAGE"
115118
docker compose "${compose_args[@]}" pull --policy always "$SERVICE"
116119

117120
echo "selfhost image deploy: restarting $SERVICE"
118-
docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE"
121+
maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-build --no-deps "$SERVICE"
119122

120123
wait_for_healthy
121124
env_put LOOPOVER_IMAGE "$IMAGE"

scripts/deploy-selfhost-prebuilt.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# SENTRY_RELEASE=gittensory-selfhost@edge-abc123 ./scripts/deploy-selfhost-prebuilt.sh
1111
# SELFHOST_COMPOSE_FILES="docker-compose.yml docker-compose.override.yml" ./scripts/deploy-selfhost-prebuilt.sh
1212
# SELFHOST_SKIP_SENTRY_UPLOAD=1 ./scripts/deploy-selfhost-prebuilt.sh
13+
# SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-prebuilt.sh # opt-in Infisical secrets (#5120), see docs
1314
set -euo pipefail
1415

1516
ENV_FILE="${SELFHOST_ENV_FILE:-.env}"
@@ -110,7 +111,7 @@ YAML
110111
docker compose "${compose_args[@]}" build "$SERVICE"
111112

112113
echo "selfhost deploy: restarting $SERVICE"
113-
docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE"
114+
maybe_infisical_run docker compose "${compose_args[@]}" up -d --no-deps "$SERVICE"
114115
}
115116

116117
require_cmd docker

scripts/lib/selfhost-deploy-common.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ env_put() {
7979
rm -f "$tmp"
8080
}
8181

82+
# Optional Infisical wrapper (#5120): when SELFHOST_USE_INFISICAL=1 (opt-in, off by default), prefixes the
83+
# given command with `infisical run --` so Infisical-sourced secrets are injected as real process env vars at
84+
# launch -- Infisical's own intended integration shape, requiring zero changes to how src/ reads env.SOMETHING.
85+
# Strictly additive: with the flag unset/0, this is a transparent passthrough and the existing .env/Docker
86+
# Compose secrets: path is completely unaffected.
87+
#
88+
# `infisical run --` only injects vars into ITS OWN child process's environment, so this must wrap the compose
89+
# `up` invocation directly (the container's actual process launch), not some earlier step -- a var it injects
90+
# is visible to `docker compose up` for interpolating `${VAR}` in docker-compose.yml's own `environment:`
91+
# blocks, but NOT to a blanket `env_file: .env` passthrough (that reads the FILE's literal contents at
92+
# container-runtime, unaffected by the calling shell's environment). See the self-hosting docs for which
93+
# variables can actually be Infisical-sourced today given that distinction.
94+
maybe_infisical_run() {
95+
if [ "${SELFHOST_USE_INFISICAL:-0}" = "1" ]; then
96+
require_cmd infisical
97+
infisical run -- "$@"
98+
else
99+
"$@"
100+
fi
101+
}
102+
82103
compose_file_args() {
83104
local files=()
84105
local file
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join, resolve } from "node:path";
4+
import { spawnSync } from "node:child_process";
5+
import { describe, expect, it } from "vitest";
6+
7+
const libPath = resolve("scripts/lib/selfhost-deploy-common.sh");
8+
9+
function readOptional(path: string): string {
10+
try {
11+
return readFileSync(path, "utf8");
12+
} catch {
13+
return "";
14+
}
15+
}
16+
17+
function createHarness() {
18+
const dir = mkdtempSync(join(tmpdir(), "gittensory-selfhost-deploy-common-"));
19+
const binDir = join(dir, "bin");
20+
const infisicalLog = join(dir, "infisical-calls.log");
21+
mkdirSync(binDir);
22+
23+
// Wraps a plain `echo` so a test can tell whether the wrapped command actually ran (its own stdout) and,
24+
// separately, whether it ran directly or via the fake infisical binary below (that binary's own log).
25+
const wrapperPath = join(dir, "wrapper.sh");
26+
writeFileSync(
27+
wrapperPath,
28+
`#!/usr/bin/env bash
29+
set -euo pipefail
30+
. "${libPath.replace(/\\/g, "/")}"
31+
maybe_infisical_run echo actual-command-ran
32+
`,
33+
);
34+
chmodSync(wrapperPath, 0o755);
35+
36+
function writeFakeInfisical() {
37+
writeFileSync(
38+
join(binDir, "infisical"),
39+
`#!/usr/bin/env bash
40+
printf '%s\\n' "$*" >> "${infisicalLog.replace(/\\/g, "/")}"
41+
if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then
42+
shift 2
43+
exec "$@"
44+
fi
45+
exit 1
46+
`,
47+
);
48+
chmodSync(join(binDir, "infisical"), 0o755);
49+
}
50+
51+
return {
52+
dir,
53+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
54+
readInfisicalCalls: () => readOptional(infisicalLog),
55+
writeFakeInfisical,
56+
run(env: Record<string, string> = {}) {
57+
return spawnSync("bash", [wrapperPath], {
58+
cwd: dir,
59+
encoding: "utf8",
60+
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}`, ...env },
61+
});
62+
},
63+
};
64+
}
65+
66+
describe("maybe_infisical_run (#5120)", () => {
67+
it("runs the wrapped command directly when SELFHOST_USE_INFISICAL is unset -- the zero-dependency default path", () => {
68+
const harness = createHarness();
69+
try {
70+
const result = harness.run();
71+
expect(result.status, result.stderr).toBe(0);
72+
expect(result.stdout).toContain("actual-command-ran");
73+
expect(harness.readInfisicalCalls()).toBe("");
74+
} finally {
75+
harness.cleanup();
76+
}
77+
});
78+
79+
it("runs the wrapped command directly when SELFHOST_USE_INFISICAL=0 (explicit opt-out, same as default)", () => {
80+
const harness = createHarness();
81+
try {
82+
const result = harness.run({ SELFHOST_USE_INFISICAL: "0" });
83+
expect(result.status, result.stderr).toBe(0);
84+
expect(result.stdout).toContain("actual-command-ran");
85+
expect(harness.readInfisicalCalls()).toBe("");
86+
} finally {
87+
harness.cleanup();
88+
}
89+
});
90+
91+
it("prefixes the command with `infisical run --` when SELFHOST_USE_INFISICAL=1 and infisical is available", () => {
92+
const harness = createHarness();
93+
harness.writeFakeInfisical();
94+
try {
95+
const result = harness.run({ SELFHOST_USE_INFISICAL: "1" });
96+
expect(result.status, result.stderr).toBe(0);
97+
// The wrapped command still genuinely ran (via infisical's own exec passthrough)...
98+
expect(result.stdout).toContain("actual-command-ran");
99+
// ...and it ran THROUGH infisical, not directly -- proving the opt-in actually wires the wrapper in.
100+
expect(harness.readInfisicalCalls()).toBe("run -- echo actual-command-ran\n");
101+
} finally {
102+
harness.cleanup();
103+
}
104+
});
105+
106+
it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => {
107+
const harness = createHarness();
108+
try {
109+
const result = harness.run({ SELFHOST_USE_INFISICAL: "1" });
110+
expect(result.status).not.toBe(0);
111+
expect(result.stderr).toContain("required command not found: infisical");
112+
expect(result.stdout).not.toContain("actual-command-ran");
113+
} finally {
114+
harness.cleanup();
115+
}
116+
});
117+
});

test/unit/selfhost-image-deploy.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function createHarness() {
3232
const binDir = join(dir, "bin");
3333
const dockerCalls = join(dir, "docker-calls.log");
3434
const dockerImages = join(dir, "docker-images.log");
35+
const infisicalCalls = join(dir, "infisical-calls.log");
3536
const envPath = join(dir, ".env");
3637

3738
mkdirSync(binDir);
@@ -83,12 +84,29 @@ exit 1
8384
);
8485
chmodSync(join(binDir, "docker"), 0o755);
8586

87+
function writeFakeInfisical() {
88+
writeFileSync(
89+
join(binDir, "infisical"),
90+
`#!/usr/bin/env bash
91+
printf '%s\\n' "$*" >> "$INFISICAL_CALLS"
92+
if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then
93+
shift 2
94+
exec "$@"
95+
fi
96+
exit 1
97+
`,
98+
);
99+
chmodSync(join(binDir, "infisical"), 0o755);
100+
}
101+
86102
return {
87103
dir,
88104
envPath,
89105
cleanup: () => rmSync(dir, { recursive: true, force: true }),
90106
readCalls: () => readOptional(dockerCalls),
91107
readImages: () => readOptional(dockerImages),
108+
readInfisicalCalls: () => readOptional(infisicalCalls),
109+
writeFakeInfisical,
92110
run(options: RunOptions = {}) {
93111
if (options.envFile !== undefined) writeFileSync(envPath, options.envFile);
94112
const result = spawnSync("bash", [scriptPath, ...(options.args ?? [])], {
@@ -102,6 +120,7 @@ exit 1
102120
DOCKER_CALLS: dockerCalls,
103121
DOCKER_IMAGES: dockerImages,
104122
DOCKER_INSPECT_STATUS: options.dockerStatus ?? "healthy",
123+
INFISICAL_CALLS: infisicalCalls,
105124
...(options.env ?? {}),
106125
},
107126
});
@@ -235,4 +254,46 @@ describe("self-host image deploy script", () => {
235254
harness.cleanup();
236255
}
237256
});
257+
258+
describe("optional Infisical wrapper (#5120)", () => {
259+
it("does not invoke infisical by default -- the restart step runs docker compose directly", () => {
260+
const { harness, result } = runHarness();
261+
try {
262+
expect(result.status, result.stderr).toBe(0);
263+
expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover");
264+
expect(harness.readInfisicalCalls()).toBe("");
265+
} finally {
266+
harness.cleanup();
267+
}
268+
});
269+
270+
it("wraps only the restart (up) step with `infisical run --` when SELFHOST_USE_INFISICAL=1", () => {
271+
const harness = createHarness();
272+
harness.writeFakeInfisical();
273+
try {
274+
const result = harness.run({ env: { SELFHOST_USE_INFISICAL: "1" } });
275+
expect(result.status, result.stderr).toBe(0);
276+
// The real docker compose invocation still happened (infisical's fake execs through to it)...
277+
expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover");
278+
// ...but only the restart step went through infisical -- pull is a plain image fetch, not a process
279+
// launch that needs injected secrets, so it must NOT be wrapped.
280+
const infisicalCalls = harness.readInfisicalCalls();
281+
expect(infisicalCalls).toContain("run -- docker compose");
282+
expect(infisicalCalls).toContain("up -d --no-build --no-deps loopover");
283+
expect(infisicalCalls).not.toContain("pull --policy always");
284+
} finally {
285+
harness.cleanup();
286+
}
287+
});
288+
289+
it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => {
290+
const { harness, result } = runHarness({ env: { SELFHOST_USE_INFISICAL: "1" } });
291+
try {
292+
expect(result.status).not.toBe(0);
293+
expect(result.stderr).toContain("required command not found: infisical");
294+
} finally {
295+
harness.cleanup();
296+
}
297+
});
298+
});
238299
});

0 commit comments

Comments
 (0)