Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
14ef3ee
feat(stack): save api tls with the stack
7ttp Nov 9, 2026
a7057cd
feat(stack): admit plain api peers by address and start check
7ttp Nov 9, 2026
bd865ef
feat(stack): serve https and plain http on the api port
7ttp Nov 9, 2026
b70899f
feat(stack): open the shared api listener with saved tls
7ttp Nov 9, 2026
5793a3d
feat(stack): apply api tls on compose and check container reach
7ttp Nov 9, 2026
427158f
feat(stack): give functions the https host api url
7ttp Nov 9, 2026
43d4625
feat(cli): load and validate api.tls for the stack
7ttp Nov 9, 2026
d35e19c
feat(cli): start the stack with api.tls and https urls
7ttp Nov 9, 2026
9fb6d70
fix(cli): trust the stack's saved cert in storage commands
7ttp Nov 9, 2026
548501c
feat(cli): print the https host url in functions serve
7ttp Nov 9, 2026
340fb97
test(cli): run the contract suite over api tls
7ttp Nov 9, 2026
44a8c78
docs(cli): document api.tls in the stack command docs
7ttp Nov 9, 2026
66b8b99
docs(stack): describe the api tls listener
7ttp Nov 9, 2026
83d2443
fix(cli): trust a ca-issued api.tls cert in stack storage
7ttp Nov 9, 2026
0636296
fix(cli): print the https url in functions serve on a database-only t…
7ttp Nov 9, 2026
90a3a5e
nit
7ttp Nov 9, 2026
42ed211
fix(stack): keep the api listener on a failed tls reopen and skip lan…
7ttp Nov 9, 2026
20fa455
test(stack): skip the lan-to-loopback admission tests on windows
7ttp Nov 9, 2026
7236033
Merge branch 'develop' into 7ttp/cli-2584-stack-support-api-tls
7ttp Nov 10, 2026
8b9f745
nit
7ttp Nov 10, 2026
c097383
Merge branch 'develop' into 7ttp/cli-2584-stack-support-api-tls
7ttp Nov 10, 2026
6f3b521
docs(cli): api.tls now supported
7ttp Nov 10, 2026
daaeda0
fix(stack): plain http from loopback only
7ttp Nov 10, 2026
b37f37d
docs(stack): tighten api.tls docs
7ttp Nov 10, 2026
a32d289
chore(stack): review nits
7ttp Nov 11, 2026
ed8eabf
Merge branch 'develop' into 7ttp/cli-2584-stack-support-api-tls
7ttp Nov 11, 2026
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
34 changes: 22 additions & 12 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,19 @@ The flag is local CLI configuration in `supabase/config.toml` or `supabase/confi
excluded from hosted project configuration. Routing applies the CLI's working-directory rules,
including `--workdir` and `SUPABASE_WORKDIR`, and prefers JSON when both files exist.

Stack mode rejects an enabled `[api.tls]` while the API is enabled, a `db.major_version` other than
15 or 17, a non-empty `experimental.s3_*` value while `db.orioledb_version` is set, an
`edge_runtime.deno_version` other than 2 while Edge Runtime is enabled, and
`analytics.backend = "bigquery"` or a non-empty `analytics.gcp_*` value while Analytics is enabled.
The error names the setting.
Stack mode rejects a `db.major_version` other than 15 or 17, a non-empty `experimental.s3_*` value
while `db.orioledb_version` is set, an `edge_runtime.deno_version` other than 2 while Edge Runtime
is enabled, and `analytics.backend = "bigquery"` or a non-empty `analytics.gcp_*` value while
Analytics is enabled. The error names the setting.

`[api.tls]` serves the API port over HTTPS, with plain HTTP only from loopback. The certificate must
have an IP subject alternative name for `127.0.0.1` (for example `mkcert localhost 127.0.0.1`;
bucket seeding and stack `storage` commands then also need
`NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"`), and
containers must reach the host from loopback: on macOS, Docker Desktop, colima and Podman machine
do; on Linux, rootful Docker, rootful Podman and rootless Podman on pasta don't, so a start that
serves the API over TLS fails there with `api.tls: unsupported networking`. The `stack start` side
effects list its errors.

Stack mode keeps its state under `SUPABASE_HOME` and never reads or writes `supabase/.temp`: Storage
uploads live in `stacks/<stack-id>/uploads`, command scratch output (pgdelta debug bundles, Functions
Expand Down Expand Up @@ -455,15 +463,17 @@ unaffected by the flag either way.

Under the stack backend, local Storage operations resolve their endpoint and credential from
the selected composition rather than `[api]`/`[api.tls]`: the endpoint is the Storage member's
observed HTTP binding plus `/storage/v1/...`, and the service-role JWT is generated from the
primary database member's observed JWT secret. The stack is located by the project root
(workdir realpath) through the `@supabase/stack` API, which reads stack state under
`SUPABASE_HOME`.
observed HTTP binding plus `/storage/v1/...`, and the service-role JWT is the one the stack saved
with its credentials. The stack is located by the project root (workdir realpath) through the
`@supabase/stack` API, which reads stack state under `SUPABASE_HOME`. A stack started with
`[api.tls]` serves that endpoint over HTTPS, and Storage uses the certificate saved at start,
not `config.toml`: a saved certificate with a self-signed root is the only trusted CA; a CA-issued
one is verified against Bun's default trust store and `NODE_EXTRA_CA_CERTS`.
Neither `storage` nor `seed buckets` ever creates a stack; the legacy `[api]`
port/external-URL/TLS fields, their `SUPABASE_API_*` overrides, `SUPABASE_AUTH_JWT_SECRET`/
`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_SERVICES_HOSTNAME`, the Docker daemon hostname, and
the embedded Kong CA are all legacy-backend only and are not consulted on this path. The
service-role JWT is never printed or logged.
`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_SERVICES_HOSTNAME` and the Docker daemon hostname are
legacy-backend only and are not consulted on this path. The service-role JWT is never printed or
logged.

Storage's capability state gates these operations:

Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/command-internal/config-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,11 @@ export function apiTlsKeyReadErrorMessage(cause: unknown): string {
return `failed to read TLS key: ${messageOf(cause)}`;
}

/** `Invalid config for api.tls: failed to load TLS key pair from ${cert} and ${key}: ${msg(cause)}` */
export function apiTlsPairErrorMessage(certPath: string, keyPath: string, cause: unknown): string {
return `Invalid config for api.tls: failed to load TLS key pair from ${certPath} and ${keyPath}: ${messageOf(cause)}`;
}

/**
* Validates only the first enabled provider in the fixed priority order; a later
* enabled-but-incomplete provider is never checked.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => {
const stack: Stack = {
id: "b".repeat(64),
services: { create: () => unused, get: () => Effect.succeed(database), list: unused },
apiTls: { get: Effect.undefined },
credentials: { get: unused },
composition: {
plan: () => unused,
Expand Down
73 changes: 69 additions & 4 deletions apps/cli/src/command-internal/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY,
DEFAULT_SIGNING_KEY,
} from "@supabase/stack/defaults";
import { type ServiceCreationInput as ServiceCreationType } from "@supabase/stack/effect";
import {
apiTlsContext,
type ServiceCreationInput as ServiceCreationType,
} from "@supabase/stack/effect";
import { type ArtifactSelection } from "@supabase/stack/internal/artifacts";
import { Effect, Data, FileSystem, Option, Path, Redacted, Schema } from "effect";
import { SecureRandom } from "effect/crypto";
Expand Down Expand Up @@ -44,7 +47,18 @@ import {
resolveAuthExternalProviders,
} from "./local-config-values.ts";
import { generateAsymmetricLocalJwt } from "./local-jwt.ts";
import { resolveEmailTemplatePaths } from "./config-validate.ts";
import {
apiTlsCertReadErrorMessage,
apiTlsKeyReadErrorMessage,
apiTlsPairErrorMessage,
resolveApiTlsPath,
resolveEmailTemplatePaths,
validateApiTlsPresence,
} from "./config-validate.ts";
import {
KONG_LOCAL_TLS_CERT,
KONG_LOCAL_TLS_KEY,
} from "../commands/start/templates/kong-local-tls.ts";
import { recordOrioleDbTelemetry } from "./db-image.ts";
import {
resolveRemoteJwks,
Expand Down Expand Up @@ -74,6 +88,11 @@ interface StackStartConfig {
stackId: string,
) => Effect.Effect<ReadonlyArray<ServiceCreationType>, StackConfigError>;
readonly source: CliConfig;
/** Reads the PEM pair the API listener serves HTTPS with, when `[api.tls]` is enabled. */
readonly apiTls: Effect.Effect<
{ readonly cert: string; readonly key: string } | undefined,
StackConfigError
>;
readonly projectEnvValues: Readonly<Record<string, string>>;
readonly originAt: CliConfigMaterialized["originAt"];
readonly remoteJwks: Effect.Effect<string | undefined, StackConfigError>;
Expand Down Expand Up @@ -169,7 +188,6 @@ export const stackEndpointKey = (service: string, endpoint: string): StackEndpoi
export const stackMajorVersionKey: StackEndpointKey = CliConfigKeys.db.majorVersion;

const unsupportedConfigPaths = [
{ path: "api.tls", active: (config: CliConfig) => config.api.enabled },
{ path: "analytics.gcp_project_id", active: (config: CliConfig) => config.analytics.enabled },
{
path: "analytics.gcp_project_number",
Expand Down Expand Up @@ -313,6 +331,52 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
);
const path = yield* Path.Path;
const { supabaseHome } = yield* CommandSettings;
/* Like legacy: both configured paths, joined to `supabase/`, else the embedded local pair. */
const tls = validatedConfig.api.tls;
const certPath = tls.cert_path ?? "";
const keyPath = tls.key_path ?? "";
const fs = yield* FileSystem.FileSystem;
const readTls = (file: string, message: (cause: unknown) => string) =>
fs
.readFileString(resolveApiTlsPath(path, projectRoot, file))
.pipe(
Effect.mapError(
(cause) => new StackConfigError({ message: message(cause.reason.cause ?? cause) }),
Comment thread
7ttp marked this conversation as resolved.
Resolved
),
);
/* Read only by the start that applies TLS; other commands use the pair saved at start. */
const apiTls = Effect.gen(function* () {
if (!tls.enabled) return undefined;
/* Like legacy, a disabled `[api]` still serves https, on the embedded pair. */
if (!validatedConfig.api.enabled)
return { cert: KONG_LOCAL_TLS_CERT, key: KONG_LOCAL_TLS_KEY };
yield* Effect.try({
try: () => validateApiTlsPresence(tls.cert_path, tls.key_path),
catch: (cause) =>
new StackConfigError({
message: cause instanceof Error ? cause.message : String(cause),
}),
});
if (certPath === "") return { cert: KONG_LOCAL_TLS_CERT, key: KONG_LOCAL_TLS_KEY };
const pair = {
cert: yield* readTls(certPath, apiTlsCertReadErrorMessage),
key: yield* readTls(keyPath, apiTlsKeyReadErrorMessage),
};
/* Proven usable here, so start never fails on it after binding. */
yield* apiTlsContext(pair).pipe(
Effect.mapError(
(error) =>
new StackConfigError({
message: apiTlsPairErrorMessage(
resolveApiTlsPath(path, projectRoot, certPath),
resolveApiTlsPath(path, projectRoot, keyPath),
error.cause,
),
}),
),
);
return pair;
});
const auth = validatedConfig.auth;
const issuer = yield* Effect.try({
try: () => {
Expand Down Expand Up @@ -566,7 +630,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
),
).pipe(
Effect.map((templates) => templates.flat()),
Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem),
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.mapError(toStackConfigError),
);
Expand Down Expand Up @@ -769,6 +833,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
selection,
creations: createCreations,
source: validatedConfig,
apiTls,
projectEnvValues,
originAt,
remoteJwks,
Expand Down
46 changes: 38 additions & 8 deletions apps/cli/src/command-internal/stack-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* endpoint; lazy Storage members may be woken by the first gateway request.
*/

import { X509Certificate } from "node:crypto";
import { Data, Effect, FileSystem, Match, Option, Path } from "effect";
import type { Observation, ServiceInstance, Stack } from "@supabase/stack/effect";
import {
Expand Down Expand Up @@ -63,10 +64,14 @@ const capabilityState = (observation: Observation | undefined) => {
return observation.lifecycle;
};

/** Resolves bucket-seeding credentials from a configured Storage instance. */
/**
* Resolves bucket-seeding credentials from a configured Storage instance; `apiCert` is the
* certificate the stack saved at start, trusted while its API serves HTTPS.
*/
export const stackStorageCredentialsFor = (
storage: ServiceInstance<"storage">,
serviceRoleKey: string,
apiCert: string | undefined,
): Effect.Effect<StorageCredentials, StackStorageUnavailableError | StackStorageCapabilityError> =>
storage.status.pipe(
Effect.mapError((cause) => new StackStorageUnavailableError({ message: cause.message })),
Expand All @@ -81,7 +86,7 @@ export const stackStorageCredentialsFor = (
return Effect.fail(
new StackStorageUnavailableError({ message: describeStorageCapability(observation) }),
);
return storageCredentialsFrom(observation, serviceRoleKey);
return storageCredentialsFrom(observation, serviceRoleKey, apiCert);
},
),
);
Expand Down Expand Up @@ -128,20 +133,42 @@ const notRunningSuggestion = (lifecycle: "starting" | "stopping" | "stopped"): s
Match.exhaustive,
);

/* Self-signed means it is its own issuer and verifies under its own key; unparsable is not. */
const selfSigned = (pem: string): Effect.Effect<boolean> =>
Effect.try(() => {
const cert = new X509Certificate(pem);
return cert.subject === cert.issuer && cert.verify(cert.publicKey);
}).pipe(Effect.orElseSucceed(() => false));

/* A saved cert with a self-signed root becomes the only trusted CA; a CA-issued one is verified
against the default trust store and `NODE_EXTRA_CA_CERTS`. */
const savedApiCa = (apiCert: string): Effect.Effect<string | undefined> =>
Effect.forEach(
apiCert.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/gu) ?? [],
selfSigned,
).pipe(Effect.map((results) => (results.includes(true) ? apiCert : undefined)));

/* An HTTPS endpoint never uses `config.toml`'s current cert; trust follows the saved one. */
const storageCredentialsFrom = (
observation: Observation,
serviceRoleKey: string,
apiCert: string | undefined,
): Effect.Effect<StorageCredentials, StackStorageCapabilityError> => {
const endpoint = observation.endpoints.find(({ name }) => name === "http");
return endpoint === undefined
? Effect.fail(
new StackStorageCapabilityError({ message: "The stack exposes no Storage endpoint." }),
)
: Effect.succeed({
baseUrl: `http://${endpoint.host}:${endpoint.port}`,
apiKey: serviceRoleKey,
localKongCa: undefined,
});
: (endpoint.scheme === "https" && apiCert !== undefined
? savedApiCa(apiCert)
: Effect.undefined
).pipe(
Effect.map((localKongCa) => ({
baseUrl: `${endpoint.scheme ?? "http"}://${endpoint.host}:${endpoint.port}`,
apiKey: serviceRoleKey,
localKongCa,
})),
);
};

/** Resolves the composition Storage endpoint without launching the owner or any member. */
Expand Down Expand Up @@ -204,7 +231,10 @@ export const stackStorageEndpointFor = (
message: "The stack has no saved credentials.",
suggestion: INSPECT_OR_RESTART_SUGGESTION,
});
return yield* storageCredentialsFrom(storageObservation, identity.serviceRoleKey);
const apiTls = yield* stack.apiTls.get.pipe(
Effect.mapError((cause) => new StackStorageUnavailableError({ message: cause.message })),
);
return yield* storageCredentialsFrom(storageObservation, identity.serviceRoleKey, apiTls?.cert);
});

/** Storage credentials for the project stack, opening it without launching the owner. */
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/diff/diff.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ const runningStackApi = (projectRoot: string) => {
const stack: Stack = {
id: "b".repeat(64),
services: { create: () => unused, get: () => Effect.succeed(database), list: unused },
apiTls: { get: Effect.undefined },
credentials: { get: unused },
composition: {
plan: () => unused,
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/dump/dump.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => {
get: () => Effect.succeed(database),
list: Effect.succeed([database]),
},
apiTls: { get: Effect.undefined },
credentials: { get: Effect.die("unused") },
composition: {
plan: () => Effect.succeed([]),
Expand Down
21 changes: 13 additions & 8 deletions apps/cli/src/commands/db/reset/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,19 @@ When the `experimental.stack` feature flag is on (`SUPABASE_EXPERIMENTAL_STACK=1
precedence, same rules as [`docs/stack-commands.md`](../../../../docs/stack-commands.md)), the
local path stops the saved composition, resets the registered database instance's data, starts
and readies that instance, reapplies the catalog overlay and migrations, then starts the saved
composition again instead of recreating containers. The reset retains the saved database
configuration and composition members; it does not rebuild the stack from current config. The
catalog overlay provisions the Auth, Storage, and Realtime schemas for the saved composition's
members, so services excluded at `stack start` stay excluded; for the database-only composition
that `db start` creates, it provisions the services enabled in current config instead. The
catalog also creates the `supabase_functions` schema, which migrations must not recreate. After
the reset, buckets are seeded — reusing the `seed buckets` local path — when Storage is running
(healthy or still starting), starting, or stopping/stopped with wake enabled; an unhealthy or
composition again instead of recreating containers. On a Docker or Podman stack started with
`[api.tls]`, that final start reruns the `api.tls` start check with a fresh nonce; if it fails, the
stack's services stop and the reset fails with `failed to start local stack services: ` followed by
the `stack start` api.tls error, which ends ``its services did not stop (<reason>), run `supabase
stack stop --stack-id <id>` to stop them`` when they don't stop. The reset retains the saved
database configuration and composition members; it does
not rebuild the stack from current config. The catalog overlay provisions the Auth, Storage,
and Realtime schemas for the saved composition's members, so services excluded at `stack
start` stay excluded; for the database-only composition that `db start` creates, it provisions
the services enabled in current config instead. The catalog also creates the
`supabase_functions` schema, which migrations must not recreate. After the reset, buckets are
seeded — reusing the `seed buckets` local path — when Storage is running (healthy or still
starting), starting, or stopping/stopped with wake enabled; an unhealthy or
errored Storage is skipped. The gateway handles lazy activation. The command never fails the
reset for a Storage problem: an unavailable service, missing credentials, a stack-gateway
activation failure, or an invalid bucket config all print `WARNING: skipped seeding storage buckets: <reason>
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/reset/reset.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ function mockResetStackApi(opts: {
},
list: Effect.succeed(members),
},
apiTls: { get: Effect.undefined },
credentials: { get: Effect.succeed(RESET_STACK_CREDENTIALS) },
composition: {
plan: () => Effect.succeed([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function generateStackApi(workdir: string) {
? Effect.succeed(database)
: Effect.fail(new StackError({ operation: "get", message: "unknown instance" })),
},
apiTls: { get: Effect.undefined },
credentials: { get: unusedStack },
composition: {
plan: () => Effect.succeed([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ function syncStackApi(workdir: string, port: number) {
? Effect.succeed(database)
: Effect.fail(new StackError({ operation: "get", message: "unknown instance" })),
},
apiTls: { get: Effect.undefined },
credentials: { get: unusedSync },
composition: {
plan: () => Effect.succeed([]),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/db/start/start.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,7 @@ describe("db start stack backend", () => {
: Effect.fail(new StackError({ operation: "get", message: `unknown ${id}` })),
list: Effect.sync(() => registered),
},
apiTls: { get: Effect.undefined },
credentials: {
get: Effect.succeed({
jwtSecret: "secret",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ owners. An owner may be launched by restart when none exists. Existing runtime
resources are stopped/recreated for composition members only. Artifacts are
resolved from `<SUPABASE_HOME or ~/.supabase>/cache/stack`; missing artifacts may
be downloaded. Shared routing/settings may read project config and profiles.
No hosted project API calls or caller-owned data deletion occur.
No hosted project API calls or caller-owned data deletion occur. While the API
port serves TLS on Docker or Podman, the database container then sends the start
check (see `stack start`) with a fresh nonce. A failed check stops the stack's
services and fails with the same `api.tls: unsupported networking on <runtime>:
...`, `api.tls: containers can't reach the api port <port> on <runtime> ...` or
`api.tls: the api port <port> on <runtime> isn't answering this stack's start
check ...` error as `stack start`; if they don't stop, the error says to run
`supabase stack stop` instead.

## Output, exit codes and telemetry

Expand Down
Loading
Loading