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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ concurrency:
permissions:
contents: read

# Docker Hub limits the anonymous pulls of an address, and the runners share
# theirs: the jobs that pull log in with a read-only token of the
# repository's secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN), as the runner
# and as root (install.sh runs as root), when the secrets are there. BuildKit
# also asks Google's mirror of Docker Hub first.
env:
BUILDKITD_MIRROR: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}

jobs:
checks:
name: Lint, typecheck, test, build
Expand Down Expand Up @@ -41,9 +52,16 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: image=moby/buildkit:v0.33.1@sha256:cec9f139f45e93c5c69c60f8b07cfad9f43f4ef6b6a6cd917527fea5ff2e3dea
buildkitd-config-inline: ${{ env.BUILDKITD_MIRROR }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
Expand All @@ -65,6 +83,12 @@ jobs:
node-version: 22
# The script only needs node; no pnpm here to cache for.
package-manager-cache: false
- name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create, call, update and delete examples/node-postgres
run: scripts/e2e-engine.sh

Expand All @@ -80,9 +104,19 @@ jobs:
with:
node-version: 22
package-manager-cache: false
- name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log root in to Docker Hub too
if: env.DOCKERHUB_USERNAME != ''
run: sudo install -D -m 600 ~/.docker/config.json /root/.docker/config.json
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver-opts: image=moby/buildkit:v0.33.1@sha256:cec9f139f45e93c5c69c60f8b07cfad9f43f4ef6b6a6cd917527fea5ff2e3dea
buildkitd-config-inline: ${{ env.BUILDKITD_MIRROR }}
- name: Build the image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
- Compose: an environment has 4 CPUs and 4096 processes to share between its services (`SPAWNER_ENV_CPUS`, `SPAWNER_ENV_PIDS`), as it has its memory, and no service gets raw sockets (`NET_RAW`). A file asking more CPUs or processes than that is refused.
- Uploads: links are followed through the links they lead to once extracted.
- CLI: `spawner login` opens http and https URLs only; untracked `.env-*`, `.npmrc`, private keys and `*.pem` files stay home like `.env` files; a Dockerfile is read only when it is a regular file of 1 MiB at most; the MCP server fences what comes from the environment and tells the model to read it as data.
- **A harder installation**:
- The installer no longer makes a token of the installation with every scope (`SPAWNER_BOOTSTRAP_TOKEN`) unless given one; installations that have one keep it until you empty its line in `.env`.
- It refuses a version older than the installed one (its database only migrates forward; `--allow-downgrade` after restoring a backup), a data directory other than the installation's, and system directories; it never makes new secrets over an installation that lost them, never upgrades without a backup (it starts Postgres for it), writes `.env` and `dns.env` in one move, keeps a trailing `=` of their values, and no longer shows the DNS credentials typed.
- The data directory is mode 700, rendered compose files and logs are readable by Spawner only, Traefik runs without capabilities but binding ports, Postgres and Traefik have memory limits, Spawner's code belongs to root, and Prisma's telemetry is off. A local install over HTTP warns that it serves every interface; the development stack listens on 127.0.0.1.
- git reaches GitHub, GitLab and Bitbucket with the host keys they publish, even the first time.
- **Source repositories are chosen by admins**: the other sources of `spawner.yaml` must come from repositories listed in the project (Projects, Edit, "Source repositories"); `spawner up` refuses the others before sending anything. A branch could otherwise make Spawner clone any repository its deploy keys reach, or an address of the internal network. The upgrade lists for each project the repositories its live environments already use.
- **Terminals and log streams close when their access goes**: deactivating a user, changing their role or revoking a token now closes the terminals and the streams of logs they had open, at once, and an expired token's within 30 seconds.
- **Uploads stop at their first limit**: an archive is read as a stream that stops at the first file, byte or decompressed byte over the limits, so that a compression bomb costs no more than a legitimate archive; a person has 5 deploys of uploaded code waiting to start at most.
Expand Down
10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ FROM node:22-bookworm-slim AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g [email protected]

FROM base AS build
RUN npm install -g [email protected]
ENV TURBO_TELEMETRY_DISABLED=1
# Set by the release workflow from its tag (2.0.0-rc.1): the version the CLI
# bundle and the API report. Empty: the version of the package.json files.
Expand Down Expand Up @@ -38,14 +38,16 @@ RUN apt-get update \
COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker:29-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
WORKDIR /app
COPY --from=build --chown=node:node /out ./
COPY --from=build --chown=node:node /repo/apps/web/dist ./web
COPY --from=build --chown=node:node /repo/apps/cli/dist/spawner.cjs ./cli/spawner
# Owned by root: Spawner, which runs as node, cannot change its own code.
COPY --from=build /out ./
COPY --from=build /repo/apps/web/dist ./web
COPY --from=build /repo/apps/cli/dist/spawner.cjs ./cli/spawner
# The installer of this version: an update from the dashboard runs it with
# --upgrade, from a short-lived container of this image. firewall.nft: the
# rules the spawner-firewall container of the stack loads on the host.
COPY install.sh firewall.nft LICENSE NOTICE ./
ENV NODE_ENV=production \
CHECKPOINT_DISABLE=1 \
PORT=3000 \
WEB_DIST_PATH=/app/web \
SPAWNER_CLI_PATH=/app/cli/spawner \
Expand Down
6 changes: 5 additions & 1 deletion apps/api/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ if [ "$(id -u)" = "0" ]; then
fi
# The data directory and its first level may come from the host or from
# Docker, as root, when it creates the mount source of Traefik: Spawner
# writes in all of them.
# writes in all of them. The data directory is Spawner's alone (the
# containers that mount parts of it do not go through it); route files
# stay readable by Traefik, which runs without capabilities.
if [ -n "$SPAWNER_DATA_DIR" ]; then
mkdir -p "$SPAWNER_DATA_DIR"
chown node:node "$SPAWNER_DATA_DIR" 2>/dev/null || true
chmod 700 "$SPAWNER_DATA_DIR" 2>/dev/null || true
find "$SPAWNER_DATA_DIR" -mindepth 1 -maxdepth 1 -type d -exec chown node:node {} + 2>/dev/null || true
find "$SPAWNER_DATA_DIR/traefik" -maxdepth 1 -type f -name '*.yaml' -exec chmod 644 {} + 2>/dev/null || true
fi
exec setpriv --reuid=node --regid=node --init-groups "$0" "$@"
fi
Expand Down
13 changes: 13 additions & 0 deletions apps/api/known_hosts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Host keys of GitHub, GitLab and Bitbucket, as they publish them (api.github.com/meta,
# docs.gitlab.com, bitbucket.org/site/ssh), checked against their published fingerprints.
# git connects to these hosts with these keys only; other hosts are trusted on first use.
# Update it when one of them rotates a key.
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
bitbucket.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDQeJzhupRu0u0cdegZIa8e86EG2qOCsIsD1Xw0xSeiPDlCr7kq97NLmMbpKTX6Esc30NuoqEEHCuc7yWtwp8dI76EEEB1VqY9QJq6vk+aySyboD5QF61I/1WeTwu+deCbgKMGbUijeXhtfbxSxm6JwGrXrhBdofTsbKRUsrN1WoNgUa8uqN1Vx6WAJw1JHPhglEGGHea6QICwJOAr/6mrui/oB7pkaWKHj3z7d1IC4KWLtY47elvjbaTlkN04Kc/5LFEirorGYVbt15kAUlqGM65pk6ZBxtaO3+30LVlORZkxOh+LKL/BvbZ/iRNhItLqNyieoQj/uh/7Iv4uyH/cV/0b4WDSd3DptigWq84lJubb9t/DnZlrJazxyDCulTmKdOR7vs9gMTo+uoIrPSb8ScTtvw65+odKAlBj59dhnVp9zd7QUojOpXlL62Aw56U4oO+FALuevvMjiWeavKhJqlR7i5n9srYcrNV7ttmDw7kf/97P5zauIhxcjX+xHv4M=
bitbucket.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPIQmuzMBuKdWeF4+a2sjSSpBK0iqitSQ+5BM9KhpexuGt20JpTVM7u5BDZngncgrqDMbWdxMWWOGtZ9UgbqgZE=
bitbucket.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIazEu89wgQZ4bqs3d63QSMzYVa0MuJ2e2gKTKqu+UUO
gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=
gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf
gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"files": [
"dist",
"prisma",
"docker-entrypoint.sh"
"docker-entrypoint.sh",
"known_hosts"
],
"scripts": {
"build": "nest build",
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/modules/engine/git-keys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface RepoKeyInfo {
}

const GLOBAL_KEY = "id_spawner";
/** apps/api/known_hosts, from dist/ as from src/. */
const PUBLISHED_KNOWN_HOSTS = path.resolve(__dirname, "..", "..", "..", "known_hosts");

/**
* SSH deploy keys. Each repository can have its own read-only key
Expand All @@ -30,6 +32,14 @@ export class GitKeysService {
return path.join(this.config.keysDir, "known_hosts");
}

/**
* The host keys of GitHub, GitLab and Bitbucket shipped with Spawner: git
* connects to them with these keys only, even the first time.
*/
get publishedKnownHostsPath(): string {
return PUBLISHED_KNOWN_HOSTS;
}

/**
* The private key to use for a repository: its own key if it has one,
* else the global key, else none.
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/modules/engine/git-mirror.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ export class GitMirrorService {

/**
* Environment of git commands: no prompt, no system config, SSH and HTTPS
* transports only, and the repository's deploy key. Hosts are trusted on
* first use and pinned in known_hosts afterwards.
* transports only, and the repository's deploy key. GitHub, GitLab and
* Bitbucket are reached with their published host keys only; other hosts
* are trusted on first use and pinned in known_hosts afterwards.
*/
private env(repoUrl: string): Record<string, string> {
const ssh = [
Expand All @@ -191,6 +192,8 @@ export class GitMirrorService {
"StrictHostKeyChecking=accept-new",
"-o",
`UserKnownHostsFile=${this.keys.knownHostsPath}`,
"-o",
`GlobalKnownHostsFile=${this.keys.publishedKnownHostsPath}`,
];
const key = this.keys.keyPathFor(repoUrl);
if (key) {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/engine/job-logs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class JobLogsService {
return;
}
const text = size + line.length > MAX_LOG_BYTES ? "[log truncated at 5 MiB]\n" : `${new Date().toISOString()} ${line}\n`;
fs.appendFileSync(this.storage.jobLogPath(jobId), text);
fs.appendFileSync(this.storage.jobLogPath(jobId), text, { mode: 0o600 });
this.sizes.set(jobId, size + Buffer.byteLength(text));
this.events.emit(jobId, text);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/engine/log-archive.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class LogArchiveService {
continue;
}
const lines = await this.docker.logLines(container.Id, { tail: SCAN_LINES }).catch(() => [] as RawLogLine[]);
fs.writeFileSync(path.join(dir, `${service}${SUFFIX}`), gzipSync(lastBytes(lines, ARCHIVE_BYTES)), { mode: 0o640 });
fs.writeFileSync(path.join(dir, `${service}${SUFFIX}`), gzipSync(lastBytes(lines, ARCHIVE_BYTES)), { mode: 0o600 });
archived.push(service);
}
return archived;
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/modules/engine/router.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const WAKE_PATH = "/api/v1/wake";

/** Traefik applies a changed file within a couple of seconds. */
const ROUTE_WAIT_MS = 15_000;
/** Route files hold no secret; Traefik, root without capabilities, reads them as any other user would. */
const ROUTES_MODE = 0o644;
const ROUTE_POLL_MS = 250;
/** Header of the answers of Spawner's waiting page. */
export const WAKE_HEADER = "x-spawner-wake";
Expand Down Expand Up @@ -114,6 +116,7 @@ export class RouterService implements OnModuleInit, OnApplicationBootstrap {
},
},
}),
ROUTES_MODE,
);
}

Expand Down Expand Up @@ -198,7 +201,7 @@ export class RouterService implements OnModuleInit, OnApplicationBootstrap {
routers[id] = { ...this.router(exposure.host, id), middlewares: exposure.auth === "none" ? PUBLIC_MIDDLEWARES : PREVIEW_MIDDLEWARES };
services[id] = { loadBalancer: { servers: [{ url: `http://${exposure.service}.${network}:${exposure.port}` }] } };
}
this.storage.writeAtomic(this.storage.traefikFile(environmentId), stringify({ http: { routers, services } }));
this.storage.writeAtomic(this.storage.traefikFile(environmentId), stringify({ http: { routers, services } }), ROUTES_MODE);
}

/**
Expand All @@ -217,7 +220,7 @@ export class RouterService implements OnModuleInit, OnApplicationBootstrap {
middlewares: exposure.auth === "none" ? [WAKE_MIDDLEWARE] : ["spawner-preview-auth", WAKE_MIDDLEWARE],
};
}
this.storage.writeAtomic(this.storage.traefikFile(environmentId), stringify({ http: { routers } }));
this.storage.writeAtomic(this.storage.traefikFile(environmentId), stringify({ http: { routers } }), ROUTES_MODE);
try {
await this.docker.disconnectNetwork(`${composeProject}_default`, this.config.traefikContainer);
} catch (error) {
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/modules/engine/storage.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,16 @@ describe("StorageService.removeTree", () => {
await expect(new StorageService(config).removeTree(lockedSource())).rejects.toMatchObject({ code: expect.stringMatching(/EACCES|EPERM/) });
});
});

describe("StorageService.writeAtomic", () => {
it("writes files Spawner alone reads, unless a mode says otherwise", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "spawner-atomic-"));
const storage = new StorageService(config);
storage.writeAtomic(path.join(root, "compose.rendered.yaml"), "secret: x\n");
storage.writeAtomic(path.join(root, "routes.yaml"), "http: {}\n", 0o644);
expect(fs.statSync(path.join(root, "compose.rendered.yaml")).mode & 0o777).toBe(0o600);
expect(fs.statSync(path.join(root, "routes.yaml")).mode & 0o777).toBe(0o644);
expect(fs.readdirSync(root).sort()).toEqual(["compose.rendered.yaml", "routes.yaml"]);
fs.rmSync(root, { recursive: true, force: true });
});
});
7 changes: 4 additions & 3 deletions apps/api/src/modules/engine/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,12 @@ export class StorageService implements OnModuleInit {

/**
* Writes a file through a temporary file and a rename, so readers such as
* Traefik never see a half-written file.
* Traefik never see a half-written file; readable by Spawner only unless
* a mode says otherwise.
*/
writeAtomic(target: string, content: string): void {
writeAtomic(target: string, content: string, mode = 0o600): void {
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporary, content, { mode: 0o640 });
fs.writeFileSync(temporary, content, { mode });
fs.renameSync(temporary, target);
}
}
11 changes: 9 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
# SPAWNER_DATA_DIR is mounted at the same path in the container, so the paths
# Spawner writes in compose files are the same for the Docker daemon.
#
# Ports listen on 127.0.0.1 only, unless SPAWNER_HTTP_BIND says otherwise
# (0.0.0.0 for the end-to-end test, whose environments call the previews
# through the host).
#
# The firewall of install.sh's stack (firewall.nft) changes the host's rules
# for every container of Docker's bridge networks: here it only starts with
# --profile firewall, as the end-to-end test does.
Expand Down Expand Up @@ -35,13 +39,16 @@ services:
image: traefik:v3.5
container_name: spawner-traefik
restart: unless-stopped
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt: [no-new-privileges:true]
command:
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--log.level=INFO"
ports:
- "${SPAWNER_HTTP_PORT:-80}:80"
- "${SPAWNER_HTTP_BIND:-127.0.0.1}:${SPAWNER_HTTP_PORT:-80}:80"
volumes:
- ${SPAWNER_DATA_DIR:?set SPAWNER_DATA_DIR to an absolute path}/traefik:/etc/traefik/dynamic:ro
networks:
Expand All @@ -56,7 +63,7 @@ services:
# A memory exhaustion of Spawner stops at its container, not at the host.
mem_limit: ${SPAWNER_MEMORY_LIMIT:-1g}
ports:
- "8080:3000"
- "${SPAWNER_HTTP_BIND:-127.0.0.1}:8080:3000"
environment:
- NODE_ENV=${NODE_ENV:-development}
- PORT=3000
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The installer sets some variables itself, in `/opt/spawner/compose.yaml` and `/o
| `SPAWNER_SECRET` | generated into `<data dir>/secret.key` | Master secret, from which the keys that sign tokens and sessions and encrypt settings derive (installer) |
| `SESSION_SECRET` | derived from the master secret | Signs the session cookie |
| `SESSION_MAX_AGE` | `86400000` (24 hours) | Dashboard sessions, in milliseconds |
| `SPAWNER_BOOTSTRAP_TOKEN` | none | Bearer token of the installation, with every scope and no user, for scripts and CI (installer) |
| `SPAWNER_BOOTSTRAP_TOKEN` | none | Bearer token of the installation, with every scope and no user, for scripts and CI; the installer sets it only when given one |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | none | GitHub login, until it is configured from the settings page |
| `GITHUB_ORG`, `GITHUB_TEAM` | none | Restrict the GitHub login to the members of an organization, and of a team |
| `GITHUB_CALLBACK_URL` | `<dashboard>/api/v1/auth/github/callback` | The OAuth callback |
Expand Down
Loading
Loading