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=
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ The fastest way to self-host Cap Web is Docker Compose:
```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
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([]);
});
});
76 changes: 76 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,76 @@
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.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();
});
});
56 changes: 47 additions & 9 deletions apps/web/content/docs/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ You can upload videos to it from the dashboard or from Cap Desktop.

<CapEmbed src="https://cap.so/embed/k310v2vf41d6ffp" />

## Quick Start (One Command)
## Quick Start

The fastest way to self-host Cap is with Docker Compose. This single command will start everything you need:
The fastest way to self-host Cap is with Docker Compose. Generate your secrets, then start the services:

```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
docker compose up -d
```

That's it! Cap will be available at `http://localhost:3000`.

<Warning title="Security Notice for Production Deployments">
The default `docker-compose.yml` includes placeholder secrets for quick local testing. **If you're deploying to a public server or production environment**, you must change these values. See the [Production Checklist](#production-checklist) below.
<Warning title="Secrets are required">
`docker compose up` stops with an error until `NEXTAUTH_SECRET`, `DATABASE_ENCRYPTION_KEY` and `MEDIA_SERVER_WEBHOOK_SECRET` are set. cap-web and the media server also refuse to start if any of them is left at a publicly known placeholder, including the example values from older compose files. Upgrading an existing install? Read [Upgrading From an Older Release](#upgrading-from-an-older-release) first. The Quick Start above creates `.env` and generates a unique value for each required secret.
</Warning>

Login links will appear in the logs since email isn't configured:
Expand All @@ -45,8 +49,9 @@ Best for VPS, home servers, or any Docker-capable host.

**Steps:**
1. Clone the repository
2. Run `docker compose up -d`
3. Access Cap at `http://localhost:3000`
2. Create `.env` and generate the three secrets using the Quick Start commands above.
3. Run `docker compose up -d`.
4. Access Cap at `http://localhost:3000`

> **Note:** The main `docker-compose.yml` builds the media server from source, so a full clone of the repository is required. The Coolify compose file uses a pre-built image from `ghcr.io` instead.

Expand Down Expand Up @@ -192,16 +197,49 @@ APPLE_CLIENT_SECRET=your-client-secret-jwt
Apple client-secret JWTs expire, so rotate the secret before its configured
expiration date.

## Upgrading From an Older Release

<Warning title="Upgrading can stop Cap from starting">
Older `docker-compose.yml` and `docker-compose.template.yml` files shipped fixed example secrets. Coolify generates unique secrets per deployment and does not require rotation for this change. Starting with this release, **cap-web and the media server refuse to start** if any of these is missing or still set to one of those public example values:

- `NEXTAUTH_SECRET` (cap-web)
- `DATABASE_ENCRYPTION_KEY` (cap-web)
- `MEDIA_SERVER_WEBHOOK_SECRET` (cap-web and the media server; it must be the same on both)

With Docker Compose, `docker compose up` stops before starting any container and names the missing variable. Otherwise the container exits on start with `Refusing to start: insecure secrets` (cap-web) or `[media-server] Refusing to start` in its logs.
</Warning>

If you never changed the example secrets, do this before you pull the new images:

1. Back up MySQL.
2. Generate a new value for each secret with `openssl rand -hex 32` and put them in the `.env` next to your compose file (or in your platform's variables). Use the same `MEDIA_SERVER_WEBHOOK_SECRET` on cap-web and the media server.
3. Update the repository with `git pull`. If you use a copy of `docker-compose.template.yml`, replace the three hardcoded secret values in your copy with the `${VARIABLE:?Set VARIABLE in .env}` entries from the updated template (or switch to the new template). Values in `.env` do not override hardcoded Compose environment values.
4. Pull the web image, rebuild the media server from the updated source, and restart: `docker compose pull && docker compose up -d --build`. When using a custom template filename, pass `-f your-compose.yml` to each Compose command.

What changes when you replace each secret:

- `NEXTAUTH_SECRET`: everyone is signed out and signs in again.
- `MEDIA_SERVER_WEBHOOK_SECRET`: nothing, as long as cap-web and the media server get the same new value.
- `DATABASE_ENCRYPTION_KEY`: values Cap encrypted with the old key can no longer be read. This covers custom S3 storage settings (organization and desktop), developer app API keys, and connected integrations. After switching keys, re-enter your storage settings and regenerate developer app keys.

### Temporarily Keeping the Old Encryption Key

If you need to upgrade before migrating encrypted data, keep the **exact existing** `DATABASE_ENCRYPTION_KEY` in `.env` and set `CAP_ALLOW_INSECURE_SECRETS=true`. The updated Compose files forward this flag to cap-web. For other platforms, set it directly on cap-web. Rotate `NEXTAUTH_SECRET` and `MEDIA_SERVER_WEBHOOK_SECRET` as described above; the media server still requires a unique webhook secret.

This opt-in permits known placeholder values but does not bypass missing-secret or encryption-key format checks. cap-web logs a prominent warning every time it starts with a placeholder. The existing key keeps encrypted storage settings, developer app keys and integrations readable, but a publicly known key leaves that data exposed. Treat this as a temporary upgrade measure: plan the data migration or reconfiguration, rotate the key, then remove `CAP_ALLOW_INSECURE_SECRETS` and restart. Do not generate a new encryption key until you are ready for the encrypted-data consequences described above.

If you already set your own values, nothing changes for you.

## Production Checklist

<Warning title="Critical: Change Default Secrets Before Going Public">
The default `docker-compose.yml` contains **hardcoded placeholder secrets** that are visible in the public repository. Anyone who knows you're using Cap with defaults could potentially:
<Warning title="Critical: Use Unique Secrets">
`docker-compose.yml` no longer ships default secrets, and Cap refuses to start with the old public placeholders. Anyone who knows a deployment's secrets could:

- **Forge authentication sessions** (via `NEXTAUTH_SECRET`)
- **Decrypt sensitive database fields** (via `DATABASE_ENCRYPTION_KEY`)
- **Spoof webhook requests** (via `MEDIA_SERVER_WEBHOOK_SECRET`)

This is fine for local development or testing on a private network, but **you must generate unique secrets before exposing Cap to the internet**.
Generate unique values even for testing.
</Warning>

**Generate secure secrets:**
Expand Down
30 changes: 29 additions & 1 deletion apps/web/instrumentation.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
S3Client,
} from "@aws-sdk/client-s3";
import { migrateDb } from "@cap/database/migrate";
import { buildEnv, serverEnv } from "@cap/env";
import {
buildEnv,
findInsecureSecrets,
formatInsecureSecrets,
REQUIRED_SELF_HOST_SECRETS,
serverEnv,
} from "@cap/env";

export async function register() {
if (process.env.NEXT_PUBLIC_IS_CAP) return;
Expand All @@ -22,6 +28,28 @@ export async function register() {
process.exit(1);
}

if (process.env.NODE_ENV === "production") {
const insecureSecrets = findInsecureSecrets(
process.env,
process.env.MEDIA_SERVER_URL || process.env.MEDIA_SERVER_WEBHOOK_SECRET
? REQUIRED_SELF_HOST_SECRETS
: ["NEXTAUTH_SECRET", "DATABASE_ENCRYPTION_KEY"],
);
if (insecureSecrets.length > 0) {
if (
process.env.CAP_ALLOW_INSECURE_SECRETS === "true" &&
insecureSecrets.every(({ problem }) => problem === "placeholder")
) {
console.warn(
`🚨 CAP_ALLOW_INSECURE_SECRETS=true: starting with publicly known secrets (${insecureSecrets.map(({ name }) => name).join(", ")}). This temporary upgrade override leaves your deployment insecure. Rotate these secrets and remove the override as soon as possible.`,
);
} else {
console.error(`🚨 ${formatInsecureSecrets(insecureSecrets)}`);
process.exit(1);
}
}
}

console.log("Waiting 5 seconds to run migrations");
// Function to trigger migrations with retry logic
const triggerMigrations = async (retryCount = 0, maxRetries = 3) => {
Expand Down
Loading