Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/loopover-ui/src/routes/docs.self-hosting-security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,92 @@ printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt
docker compose up -d --no-deps loopover`}
/>

<h2>Optional: Infisical secrets management</h2>
<p>
The hardened default above — <code>.env</code> plus Docker Compose <code>secrets:</code> —
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{" "}
<a href="https://infisical.com" target="_blank" rel="noreferrer">
Infisical
</a>{" "}
— an open-source, self-hostable secrets manager. This is{" "}
<strong>strictly optional and additive</strong>: skip this section entirely and the hardened{" "}
<code>.env</code>/Docker secrets default keeps working unchanged.
</p>
<Callout variant="note" title="No application code changes">
Infisical wires in at the deploy-script level via its own{" "}
<code>infisical run -- &lt;command&gt;</code> wrapper, which injects secrets as real process
environment variables at container launch. Nothing under <code>src/</code> knows or cares
whether a given <code>env.SOMETHING</code> value came from Infisical, <code>.env</code>, or
a Docker secret file.
</Callout>

<h3>Setup: cloud or self-hosted</h3>
<ol>
<li>
Install the{" "}
<a href="https://infisical.com/docs/cli/overview" target="_blank" rel="noreferrer">
Infisical CLI
</a>{" "}
on the machine that runs the deploy script (not inside the app container).
</li>
<li>
Pick where your secrets live: Infisical Cloud (the default, zero infrastructure of your
own) or a self-hosted Infisical instance — if you're already self-hosting LoopOver, you
can self-host Infisical alongside it. Either way, run <code>infisical login</code> once,
then <code>infisical init</code> from the repo root to link a local{" "}
<code>.infisical.json</code> to an Infisical project.
</li>
<li>
Create an environment inside that project (e.g. <code>prod</code>) matching how you think
about this deployment, and add the secrets you want Infisical to manage — same variable
names your <code>.env</code>/<code>docker-compose.yml</code> already use (
<code>GITHUB_APP_PRIVATE_KEY</code>, <code>GITHUB_WEBHOOK_SECRET</code>, provider API
keys, and so on).
</li>
<li>Opt in when deploying:</li>
</ol>
<CodeBlock
filename="shell"
code={`SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-image.sh
# or
SELFHOST_USE_INFISICAL=1 ./scripts/deploy-selfhost-prebuilt.sh`}
/>
<p>
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{" "}
<code>infisical run --</code>; a missing <code>infisical</code> binary fails the deploy
immediately with a clear error rather than silently deploying without the secrets you asked
for.
</p>

<h3>Interaction with .env and Docker secrets — do not mix the same variable</h3>
<Callout
variant="warn"
title="Infisical only reaches variables interpolated in docker-compose.yml"
>
<code>infisical run --</code> injects secrets into its own child process's environment — in
this case, the <code>docker compose up</code> invocation. Docker Compose only lets a host
shell variable reach the container for an <code>environment:</code> entry written as{" "}
<code>{`SOMEVAR: "\${SOMEVAR}"`}</code>. It does <strong>not</strong> reach a plain{" "}
<code>env_file: .env</code> 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 <code>_FILE</code> convention and <code>env_file: .env</code>, not through{" "}
<code>environment:</code> interpolation — an Infisical value for one of those exact names,
by itself, will <strong>not</strong> reach the container today. Infisical is the right fit
for <em>other</em> variables you reference via <code>{`"\${VAR}"`}</code> interpolation in
your own <code>docker-compose.override.yml</code> (a provider API key you add yourself, for
example) — not a drop-in override for the pre-wired native-secrets list.
</Callout>
<p>
The safest rule of thumb: for any given variable, pick <em>one</em> source — Infisical or a
plain <code>.env</code>/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.
</p>

<h2>Private policy</h2>
<p>
Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in
Expand Down
5 changes: 4 additions & 1 deletion scripts/deploy-selfhost-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion scripts/deploy-selfhost-prebuilt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions scripts/lib/selfhost-deploy-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions test/unit/selfhost-deploy-common.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
return spawnSync("bash", [wrapperPath], {
cwd: dir,
encoding: "utf8",
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}`, ...env },
});
},
};
}

describe("maybe_infisical_run (#5120)", () => {
it("runs the wrapped command directly when SELFHOST_USE_INFISICAL is unset -- the zero-dependency default path", () => {
const harness = createHarness();
try {
const result = harness.run();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("actual-command-ran");
expect(harness.readInfisicalCalls()).toBe("");
} finally {
harness.cleanup();
}
});

it("runs the wrapped command directly when SELFHOST_USE_INFISICAL=0 (explicit opt-out, same as default)", () => {
const harness = createHarness();
try {
const result = harness.run({ SELFHOST_USE_INFISICAL: "0" });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("actual-command-ran");
expect(harness.readInfisicalCalls()).toBe("");
} finally {
harness.cleanup();
}
});

it("prefixes the command with `infisical run --` when SELFHOST_USE_INFISICAL=1 and infisical is available", () => {
const harness = createHarness();
harness.writeFakeInfisical();
try {
const result = harness.run({ SELFHOST_USE_INFISICAL: "1" });
expect(result.status, result.stderr).toBe(0);
// The wrapped command still genuinely ran (via infisical's own exec passthrough)...
expect(result.stdout).toContain("actual-command-ran");
// ...and it ran THROUGH infisical, not directly -- proving the opt-in actually wires the wrapper in.
expect(harness.readInfisicalCalls()).toBe("run -- echo actual-command-ran\n");
} finally {
harness.cleanup();
}
});

it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => {
const harness = createHarness();
try {
const result = harness.run({ SELFHOST_USE_INFISICAL: "1" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("required command not found: infisical");
expect(result.stdout).not.toContain("actual-command-ran");
} finally {
harness.cleanup();
}
});
});
61 changes: 61 additions & 0 deletions test/unit/selfhost-image-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function createHarness() {
const binDir = join(dir, "bin");
const dockerCalls = join(dir, "docker-calls.log");
const dockerImages = join(dir, "docker-images.log");
const infisicalCalls = join(dir, "infisical-calls.log");
const envPath = join(dir, ".env");

mkdirSync(binDir);
Expand Down Expand Up @@ -83,12 +84,29 @@ exit 1
);
chmodSync(join(binDir, "docker"), 0o755);

function writeFakeInfisical() {
writeFileSync(
join(binDir, "infisical"),
`#!/usr/bin/env bash
printf '%s\\n' "$*" >> "$INFISICAL_CALLS"
if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "--" ]; then
shift 2
exec "$@"
fi
exit 1
`,
);
chmodSync(join(binDir, "infisical"), 0o755);
}

return {
dir,
envPath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
readCalls: () => readOptional(dockerCalls),
readImages: () => readOptional(dockerImages),
readInfisicalCalls: () => readOptional(infisicalCalls),
writeFakeInfisical,
run(options: RunOptions = {}) {
if (options.envFile !== undefined) writeFileSync(envPath, options.envFile);
const result = spawnSync("bash", [scriptPath, ...(options.args ?? [])], {
Expand All @@ -102,6 +120,7 @@ exit 1
DOCKER_CALLS: dockerCalls,
DOCKER_IMAGES: dockerImages,
DOCKER_INSPECT_STATUS: options.dockerStatus ?? "healthy",
INFISICAL_CALLS: infisicalCalls,
...(options.env ?? {}),
},
});
Expand Down Expand Up @@ -235,4 +254,46 @@ describe("self-host image deploy script", () => {
harness.cleanup();
}
});

describe("optional Infisical wrapper (#5120)", () => {
it("does not invoke infisical by default -- the restart step runs docker compose directly", () => {
const { harness, result } = runHarness();
try {
expect(result.status, result.stderr).toBe(0);
expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover");
expect(harness.readInfisicalCalls()).toBe("");
} finally {
harness.cleanup();
}
});

it("wraps only the restart (up) step with `infisical run --` when SELFHOST_USE_INFISICAL=1", () => {
const harness = createHarness();
harness.writeFakeInfisical();
try {
const result = harness.run({ env: { SELFHOST_USE_INFISICAL: "1" } });
expect(result.status, result.stderr).toBe(0);
// The real docker compose invocation still happened (infisical's fake execs through to it)...
expect(harness.readCalls()).toContain("up -d --no-build --no-deps loopover");
// ...but only the restart step went through infisical -- pull is a plain image fetch, not a process
// launch that needs injected secrets, so it must NOT be wrapped.
const infisicalCalls = harness.readInfisicalCalls();
expect(infisicalCalls).toContain("run -- docker compose");
expect(infisicalCalls).toContain("up -d --no-build --no-deps loopover");
expect(infisicalCalls).not.toContain("pull --policy always");
} finally {
harness.cleanup();
}
});

it("fails closed with a clear error when SELFHOST_USE_INFISICAL=1 but infisical is not installed", () => {
const { harness, result } = runHarness({ env: { SELFHOST_USE_INFISICAL: "1" } });
try {
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("required command not found: infisical");
} finally {
harness.cleanup();
}
});
});
});
Loading