Skip to content
Open
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
20 changes: 20 additions & 0 deletions .env.self-hosting.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copy to .env next to docker-compose.yml and fill in every value.
# Generate each secret with: openssl rand -hex 32
# cap-web and the media server refuse to start when these are missing or left at a known placeholder.
NEXTAUTH_SECRET=
DATABASE_ENCRYPTION_KEY=
MEDIA_SERVER_WEBHOOK_SECRET=

# Optional: database and storage passwords (internal-only defaults are used when unset)
MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD=
MINIO_ROOT_USER=cap-admin
MINIO_ROOT_PASSWORD=

# Public URLs
CAP_URL=http://localhost:3000
S3_PUBLIC_URL=http://localhost:9000

# Optional: email. Without both values, login codes are printed to the cap-web logs.
RESEND_API_KEY=
RESEND_FROM_DOMAIN=
30 changes: 30 additions & 0 deletions .github/workflows/test-self-hosting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ on:
- main
paths:
- "docker-compose.yml"
- "docker-compose.template.yml"
- "docker-compose.coolify.yml"
- ".env.self-hosting.example"
- "apps/media-server/**"
- "apps/web/Dockerfile"
- ".github/workflows/test-self-hosting.yml"
pull_request:
paths:
- "docker-compose.yml"
- "docker-compose.template.yml"
- "docker-compose.coolify.yml"
- ".env.self-hosting.example"
- "apps/media-server/**"
- "apps/web/Dockerfile"
- ".github/workflows/test-self-hosting.yml"
Expand All @@ -29,6 +33,32 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Configure self-hosting secrets
run: |
cp .env.self-hosting.example .env
for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do
secret=$(openssl rand -hex 32)
echo "::add-mask::$secret"
sed -i.bak "s/^$name=.*/$name=$secret/" .env
done
rm .env.bak

- name: Validate required Compose secrets
run: |
for file in docker-compose.yml docker-compose.template.yml; do
docker compose -f "$file" config --quiet
for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do
if env "$name=" docker compose -f "$file" config --quiet > "$RUNNER_TEMP/cap-compose-validation.log" 2>&1; then
echo "Expected $file to reject an empty $name"
exit 1
fi
if ! grep -q "$name" "$RUNNER_TEMP/cap-compose-validation.log"; then
echo "$file did not identify the missing $name"
exit 1
fi
done
done

- name: Start Cap with Docker Compose
run: |
echo "Starting Cap..."
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dist
dist-ssr
*.local
.env
.env.bak
.env.production
.env.development
.env.test
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<p align="center">

Check failure on line 1 in README.md

View workflow job for this annotation

GitHub Actions / Lint (Biome)

format

File content differs from formatting output
<img width="150" height="150" src="https://github.com/CapSoftware/Cap/blob/main/apps/desktop/src-tauri/icons/Square310x310Logo.png" alt="Cap logo">
</p>

Expand Down Expand Up @@ -80,6 +80,11 @@
```bash
git clone https://github.com/CapSoftware/Cap.git
cd Cap
cp .env.self-hosting.example .env
for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do
sed -i.bak "s/^$name=.*/$name=$(openssl rand -hex 32)/" .env
done
rm .env.bak
docker compose up -d
```

Expand Down
34 changes: 34 additions & 0 deletions apps/media-server/src/__tests__/lib/startup-checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { getWebhookSecretProblem } from "../../lib/startup-checks";

describe("getWebhookSecretProblem", () => {
test("rejects a missing secret", () => {
expect(getWebhookSecretProblem({})).toContain("not set");
});

test("rejects the old docker-compose placeholder", () => {
expect(
getWebhookSecretProblem({
MEDIA_SERVER_WEBHOOK_SECRET:
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
}),
).toContain("placeholder");
});

test("allows a placeholder only with the explicit local override", () => {
expect(
getWebhookSecretProblem({
MEDIA_SERVER_WEBHOOK_SECRET: "local-media-server-secret",
MEDIA_SERVER_ALLOW_INSECURE_SECRET: "true",
}),
).toBeNull();
});

test("accepts a unique secret", () => {
expect(
getWebhookSecretProblem({
MEDIA_SERVER_WEBHOOK_SECRET: "4b1f0c9e7a2d43e8b6c5f1a09d8e7c6b",
}),
).toBeNull();
});
});
3 changes: 3 additions & 0 deletions apps/media-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
cleanupMediaTransferCache,
sweepMediaTransferCache,
} from "./lib/media-transfer";
import { assertSecureStartup } from "./lib/startup-checks";
import { removeOwnTempDir, startTempDirMaintenance } from "./lib/temp-files";

assertSecureStartup();

const port = Number(process.env.PORT) || 3456;

console.log(`[media-server] Starting on port ${port}`);
Expand Down
39 changes: 39 additions & 0 deletions apps/media-server/src/lib/startup-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const KNOWN_PLACEHOLDER_SECRETS = new Set([
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9",
"fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1",
"local-media-server-secret",
"changeme",
"change-me",
"secret",
]);

export function getWebhookSecretProblem(
env: Record<string, string | undefined> = process.env,
): string | null {
const value = env.MEDIA_SERVER_WEBHOOK_SECRET?.trim();
if (!value) return "MEDIA_SERVER_WEBHOOK_SECRET is not set";
if (
KNOWN_PLACEHOLDER_SECRETS.has(value.toLowerCase()) &&
env.MEDIA_SERVER_ALLOW_INSECURE_SECRET !== "true"
) {
return "MEDIA_SERVER_WEBHOOK_SECRET is set to a publicly known placeholder value";
}
return null;
}

export function assertSecureStartup(
env: Record<string, string | undefined> = process.env,
) {
const problem = getWebhookSecretProblem(env);
if (!problem) return;
console.error(
[
`[media-server] Refusing to start: ${problem}.`,
"Generate one with `openssl rand -hex 32` and set the same value on cap-web and the media server.",
].join("\n"),
);
process.exit(1);
}
40 changes: 40 additions & 0 deletions apps/web/__tests__/unit/insecure-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { findInsecureSecrets, formatInsecureSecrets } from "@cap/env";
import { describe, expect, it } from "vitest";

const strong =
"9f1c2b7e4d5a6f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708";

describe("findInsecureSecrets", () => {
it("flags missing and placeholder secrets", () => {
const problems = findInsecureSecrets({
NEXTAUTH_SECRET:
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
DATABASE_ENCRYPTION_KEY: "",
MEDIA_SERVER_WEBHOOK_SECRET: strong,
});
expect(problems).toEqual([
{ name: "NEXTAUTH_SECRET", problem: "placeholder" },
{ name: "DATABASE_ENCRYPTION_KEY", problem: "missing" },
]);
expect(formatInsecureSecrets(problems)).toContain("openssl rand -hex 32");
});

it("accepts unique secrets", () => {
expect(
findInsecureSecrets({
NEXTAUTH_SECRET: strong,
DATABASE_ENCRYPTION_KEY: `${strong.slice(1)}0`,
MEDIA_SERVER_WEBHOOK_SECRET: `${strong.slice(2)}00`,
}),
).toEqual([]);
});

it("only checks the requested names", () => {
expect(
findInsecureSecrets(
{ NEXTAUTH_SECRET: strong, DATABASE_ENCRYPTION_KEY: strong },
["NEXTAUTH_SECRET", "DATABASE_ENCRYPTION_KEY"],
),
).toEqual([]);
});
});
101 changes: 101 additions & 0 deletions apps/web/__tests__/unit/self-host-startup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const placeholder =
"c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9";

async function register() {
vi.resetModules();
const instrumentation = await import("../../instrumentation.node");
await instrumentation.register();
}

describe("self-host startup secrets", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_IS_CAP", "");
vi.stubEnv("DATABASE_URL", "mysql://localhost/cap");
vi.stubEnv("WEB_URL", "http://localhost:3000");
vi.stubEnv("NEXTAUTH_URL", "http://localhost:3000");
vi.stubEnv("NEXTAUTH_SECRET", "ab".repeat(32));
vi.stubEnv("DATABASE_ENCRYPTION_KEY", placeholder);
vi.stubEnv("CAP_AWS_BUCKET", "cap");
vi.stubEnv("CAP_AWS_REGION", "us-east-1");
vi.stubEnv("MEDIA_SERVER_URL", "http://media-server:3456");
vi.stubEnv("MEDIA_SERVER_WEBHOOK_SECRET", "cd".repeat(32));
vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "false");
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
});

afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
});

it("refuses a placeholder by default", async () => {
await expect(register()).rejects.toThrow("exit");
expect(process.exit).toHaveBeenCalledWith(1);
});

it("keeps the old encryption key with an explicit override and warns on every start", async () => {
vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true");
await register();
await register();
expect(process.exit).not.toHaveBeenCalled();
expect(process.env.DATABASE_ENCRYPTION_KEY).toBe(placeholder);
expect(console.warn).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("DATABASE_ENCRYPTION_KEY"),
);
expect(console.warn).not.toHaveBeenCalledWith(
expect.stringContaining(placeholder),
);
});

it("can decrypt existing data after upgrading with the old encryption key", async () => {
vi.resetModules();
const { encrypt } = await import("@cap/database/crypto");
const storedCredentials = await encrypt("existing-storage-credentials");
vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true");

await register();

const { decrypt } = await import("@cap/database/crypto");
expect(await decrypt(storedCredentials)).toBe(
"existing-storage-credentials",
);
});

it.each(["NEXTAUTH_SECRET", "MEDIA_SERVER_WEBHOOK_SECRET"])(
"does not bypass a missing %s with the upgrade override",
async (name) => {
vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true");
vi.stubEnv(name, "");
await expect(register()).rejects.toThrow("exit");
expect(process.exit).toHaveBeenCalledWith(1);
expect(vi.getTimerCount()).toBe(0);
},
);

it.each(["", "invalid"])(
"does not bypass missing or invalid encryption keys (%j)",
async (key) => {
vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true");
vi.stubEnv("DATABASE_ENCRYPTION_KEY", key);
await expect(register()).rejects.toThrow("exit");
expect(process.exit).toHaveBeenCalledWith(1);
},
);

it("starts normally with unique secrets", async () => {
vi.stubEnv("DATABASE_ENCRYPTION_KEY", "ef".repeat(32));
await register();
expect(process.exit).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Required Secrets for Self-Hosted Upgrades
publishedAt: "2026-10-24"
---

Self-hosted Cap now refuses to start with missing secrets or the public example values shipped in older Compose files. Generate unique `NEXTAUTH_SECRET`, `DATABASE_ENCRYPTION_KEY` and `MEDIA_SERVER_WEBHOOK_SECRET` values before a new installation. Use the same webhook secret on cap-web and the media server. Coolify already generates unique secrets per deployment.

## Upgrading an Existing Installation

Back up MySQL and read the [upgrade instructions](/docs/self-hosting#upgrading-from-an-older-release) before changing secrets. Update the repository and any copied Compose template, then pull images and rebuild the media server from the updated source. Adding values to `.env` does not replace secrets hardcoded in an older template.

Changing `DATABASE_ENCRYPTION_KEY` makes existing encrypted storage settings, developer app keys and integration credentials unreadable. To upgrade before migrating that data, retain the exact existing valid encryption key and explicitly set `CAP_ALLOW_INSECURE_SECRETS=true` on cap-web. This temporary override logs a warning on every affected startup and keeps missing-secret and key-format checks enforced. Rotate authentication and webhook secrets, plan the encrypted-data migration, and remove the override after rotating the encryption key.
Loading
Loading