Skip to content
  1. Operate
  2. Self-hosting

Operate

Self-hosting

Everything needed to build the image and hand it to whatever runs it. This repository stops at an image that builds: publishing images, registries, deployment pipelines, infrastructure, DNS, TLS, CDN and rollout live outside it (ADR-0008).

FilePurpose
DockerfileThe one multi-stage image. Roles api, worker, migrate and check are selected by the container command.
compose.dev.yamlLocal development dependencies (PostgreSQL 18). Not a production topology.
../.github/workflows/ci.ymlLint, build, typecheck, tests (integration tests run against a PostgreSQL service container), secret scan, and an image build that is then exercised: exit codes, migrate, readiness, non-root user, clean shutdown. It needs no secrets.
../.github/workflows/release.ymlVersions and publishes the npm packages from the pending changesets: a pull request that bumps versions and changelogs, and, once it is merged, a publish through the registry's trusted publishing, with provenance and no stored secret (ADR-0046).

A compose bundle and install script for self-hosting are planned (roadmap).

Build and run locally

docker build -f deploy/Dockerfile -t skillcdn .      # from the repository root
docker run --rm skillcdn migrate                     # one-off role
docker run --rm -p 11188:11188 --env-file .env skillcdn api
docker run --rm -v "$PWD:/repo:ro" skillcdn check /repo    # what an agent would get from this directory

The image runs as a non-root user, contains production dependencies only, and has no secrets and no configuration baked in. It carries the fonts of the web UI under their own license; see THIRD-PARTY-NOTICES.md.

Environment contract

The server is configured only through environment variables. .env.example is the source of truth; this table adds what operators need to know. Both are updated in the same change as the config module.

VariableRolesRequiredSecretNotes
NODE_ENVallnonoThe image sets production.
LOG_LEVELallnonoDefault info.
HOST, PORTapinonoDefaults 0.0.0.0 and 11188.
SHUTDOWN_GRACE_SECONDSapinonoDefault 20. Keep the platform's stop timeout above it.
HTTP_KEEP_ALIVE_SECONDS, HTTP_REQUEST_TIMEOUT_SECONDSapinonoDefaults 65 and 60. See Behind a reverse proxy.
ACCESS_LOGapinonoDefault true: one log line per request, probes excluded. Each line carries the client address and user agent: keep the log only as long as your privacy policy says, or turn it off. The usage statistics hold neither.
TRUSTED_PROXIESapinonoAddresses or CIDR networks whose forwarding headers are believed. Default: none.
CLIENT_IP_HEADER, REQUEST_ID_HEADERapinonoDefaults x-forwarded-for and x-request-id. Read only from trusted proxies.
DATABASE_URLallyesyesPostgreSQL connection string.
DATABASE_POOL_MAXapinonoDefault 10 connections per process.
GITHUB_API_URLapi, workernonoDefault https://api.github.com. GitHub Enterprise Server: https://<host>/api/v3.
GITHUB_TOKENapi, workernoyesOptional, no scopes needed. Raises the GitHub rate limit for public-repo reads; without it the anonymous limit applies to the whole deployment.
GITHUB_WEB_URLapinonoWhere people sign in at the git host. Default: derived from GITHUB_API_URL (https://github.com, or the origin of a GitHub Enterprise Server).
GITHUB_APP_ID, GITHUB_APP_CLIENT_IDapinonoThe id and the client id of the GitHub App people sign in through. See Signing in and private repositories.
GITHUB_APP_PRIVATE_KEY, GITHUB_APP_CLIENT_SECRETapinoyesThe app's private key in PEM format (a value with \n for line breaks is accepted; GITHUB_APP_PRIVATE_KEY_FILE is the natural way to mount one) and its client secret.
GITHUB_APP_WEBHOOK_SECRETapinoyesThe secret of the app's webhook, at least 32 characters. Set, the server receives the app's events at /webhooks/gh, verifies each against this secret, and ends what it remembers when they say so (ADR-0038). Unset: there is no such route, and what is remembered ends when its time is up. Read only together with the app's other variables.
AUTH_SECRETapinoyesWhat the tokens of the git host are encrypted under at rest, and what requests in flight are sealed with. At least 32 characters, the same on every replica. Changing it signs everyone out. Signing in exists only when this and the four app variables are all set; setting some of them is a configuration error.
SESSION_TTL_DAYSapinonoHow long a browser stays signed in without being used. Default 30.
ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_DAYSapinonoHow long an AI app's access token lasts, and how long it can renew it without the person. Defaults 3600 and 30.
PERMISSION_TTL_SECONDSapinonoHow long the git host's answer about what a person can see is believed: someone removed at the host keeps access for at most this long. Default 60; 0 asks on every request.
WEB_ROOTapinonoDirectory of a web UI build. The image sets /app/web; set it to an empty value to run without a UI. The build's render module runs inside the server, so only ever point this at a build made from this repository.
PUBLIC_URLapinonoThe origin visitors use, such as https://skills.example.com. Goes into canonical links, the sitemap and the URLs pages show. Unset: the origin of each request, which is right behind one hostname and wrong behind several; set it for any public deployment. Required for signing in: it is where the git host sends people back, the issuer of every token, and what the session cookie is bound to.
ADMIN_TOKENapinoyesBearer token of the admin API. At least 32 characters. Unset: the admin API does not exist.
GOOGLE_SITE_VERIFICATIONapinonoThe content of the google-site-verification meta tag a search console asks for; written into the head of every page.
GOOGLE_ANALYTICS_IDapinonoA Google Analytics measurement id (G-...). Set, every page asks the visitor once, in a banner, and loads the analytics script only after they agree; consent mode starts with everything denied, and a browser that signals Global Privacy Control or Do Not Track is never asked and never loads it. The content security policy allows the script's sources and nothing else new.
TERMS_URL, PRIVACY_URLapinonoWhere the deployment's terms of service and privacy policy are, when they are hosted elsewhere. A page written into the deployment through the admin API is served at /terms or /privacy and comes first (ADR-0029). Whichever exists is written into the head of every page as a standard link type and shown in the footer. See Operating a public deployment.
CONTACT_EMAILapinonoWhom to write to about content: takedown requests and reports. Shown in the footer as a link, only when set.
USAGE_STATS, USAGE_STATS_FLUSH_SECONDSapinonoDaily counts per public repository (connections, tool calls, skill loads, distinct clients), without anything that identifies a client: addresses are hashed under the key of the day, and the rows of a day are folded into a count and deleted when it is over. Defaults true and 15.
USAGE_HASH_SECRETapinoyesWhat client hashes are keyed from (ADR-0027); at least 32 characters. Unset, each process makes its own at start, and replicas or restarts within a day count a client more than once. Set it for more than one replica, or for exact counts.
REPO_TTL_SECONDS, REF_TTL_SECONDSapinonoHow long repository facts and moving refs are trusted before revalidation. Default 60 each.
INDEX_WAIT_MSapinonoHow long a tool call waits for a new commit's index. Default 20000.
INDEX_CONCURRENCY, INDEX_LEASE_SECONDSapi, workernonoCommits indexed at once per process, and the lifetime of an indexing claim. INDEX_CONCURRENCY=0 makes a process index nothing and serve only what another process, on any version, has indexed.
INDEX_MAX_*, READ_MAX_FILE_BYTESapi, worker, checknonoLimits on the work one repository may cause, including the unpacked size of a commit archive; see .env.example. A file over READ_MAX_FILE_BYTES is not served: the skill is listed without it and names it with where to fetch it (ADR-0033). The decision is made when a commit is indexed, so a changed limit shows after re-indexing. check and the skillcdn check command read these and nothing else.

Invalid configuration stops the process with exit code 78 and a message that names the variable and the rule, never the value.

Every secret NAME may instead be provided as NAME_FILE, a path to a file holding the value, so secret mounts work. Secrets are injected at runtime by the platform (for example a task definition that references a secret store). They are never build arguments, image layers or committed files.

The admin API

The operator's lists and the takedown (ADR-0026) are managed through /admin/v1, which exists only while ADMIN_TOKEN is set and answers to nothing else than that token as a bearer token. Keep it off the public internet where you can: it is meant for an operator's shell, not for browsers. Every answer is JSON and never cached.

RequestWhat it does
`GET /admin/v1/repositories[?kind=verifiedfeatured
PUT /admin/v1/repositories/<kind>/gh/<owner>/<repo>[][/path]Adds an entry and answers with the address it was stored under. A verified or blocked entry names a repository, without a ref or a path; a featured one is any address. Adding what is already there changes nothing.
DELETE /admin/v1/repositories/<kind>/gh/<owner>/<repo>[][/path]Removes an entry; 404 when there was none.
POST /admin/v1/purge/gh/<owner>/<repo>Removes what was indexed for the repository: its snapshots, their index entries, its cached refs, and the file bodies nothing references any more. Answers with the counts, or 404 when the repository was never indexed. The next request for the repository indexes it again, unless it is blocked as well.
GET /admin/v1/showcaseThe entries the front page leads with (ADR-0028), as GET /api/v1/showcase shows them to everyone.
PUT /admin/v1/showcase/<id>Writes the entry named <id> (a lowercase slug) from a JSON body, replacing what was there; the body is described below. Answers with the entry as the pages read it, or 400 with problems that say what is wrong.
DELETE /admin/v1/showcase/<id>Removes the entry. Its uploads stay.
GET /admin/v1/imagesThe pictures of addresses (ADR-0031): the address, the upload's hash or null, and the URL the pages load the picture from.
PUT /admin/v1/images/gh/<owner>/<repo>[@ref][/path]Gives the address a picture, replacing what was there, from a JSON body with exactly one of media (the hash of an upload that is a picture) and url (an https URL). The explorer's card and the page of that address, and of every address of that repository when the picture is the repository's, show it in place of what the repository's manifest declares. A URL keeps the picture's traffic off the deployment; an upload is served from it.
DELETE /admin/v1/images/gh/<owner>/<repo>[@ref][/path]Removes the picture; 404 when there was none. The repository's own picture, when it declares one, shows again.
GET /admin/v1/mediaThe uploads: hash, type, size, URL, and which entries and addresses use each.
POST /admin/v1/mediaStores the request body as an upload of the content-type sent: video/mp4, video/webm, image/avif, image/webp, image/png, image/jpeg or image/gif, at most 16 MiB. Answers with its sha, the SHA-256 of the bytes, and the url it is served at for everyone, /media/<sha>, immutably. The same bytes are the same upload.
DELETE /admin/v1/media/<sha>Removes an upload; 409 while an entry or the picture of an address uses it.
GET /admin/v1/legalThe deployment's own pages that have been written (ADR-0029): its terms of service and its privacy policy.
PUT /admin/v1/legal/<kind>Writes the page of <kind> (terms or privacy) from a JSON body, replacing what was there: texts by language tag, each with a title and a body in Markdown, and an optional revised date (YYYY-MM-DD). The page is then served at /terms or /privacy on the deployment's own origin, and every page links to it.
DELETE /admin/v1/legal/<kind>Removes the page. The configured URL, when there is one, is linked again.

What the lists do: a verified repository carries no provenance notice on its default branch; a featured address is shown by the explorer, and while there is none the reference repository of this project is; a blocked repository answers exactly like one that does not exist. The sitemap lists the featured addresses and the verified repositories, minus anything blocked. A process sees a change made through another process within thirty seconds.

curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" https://skills.example.com/admin/v1/repositories/blocked/gh/owner/repo
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://skills.example.com/admin/v1/purge/gh/owner/repo

The same purge runs without the API, from a shell with the database configured: node dist/main.js purge /gh/owner/repo.

What the front page leads with is the showcase: a list of entries the operator writes, each a card with media and words, in order. Until the operator writes one, the page shows the build's own showcase of the reference repository. Upload the media first, then write the entry that names it:

curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: image/webp" --data-binary @poster.webp https://skills.example.com/admin/v1/media
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: video/mp4" --data-binary @clip.mp4 https://skills.example.com/admin/v1/media
curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: application/json" --data @entry.json https://skills.example.com/admin/v1/showcase/spring-ad

An entry is a JSON document:

  • address: where the card leads, any address such as /gh/owner/repo or /gh/owner/repo@main/skills/ad.
  • position: its place in the list, lowest first; 0 when left out.
  • width and height: the pixel size of the clip and its poster.
  • media: uploads by hash. poster is required; clip (a video), animation (the clip as an animated image, for browsers that will not play video), reference and picture (what the example conversation attaches) and social (a 1200 x 630 picture for link previews) are optional. A clip needs durationMs, one pass of it, and published, its date as YYYY-MM-DD.
  • texts: the words by language tag (en, ko), at least one language. Each has title, body and action (the button), and optionally tags (up to four), requirement (what the skill needs besides the AI app), clip (what the clip shows, for whoever cannot see it), credit, and demo, the example conversation: prompt (what the person asks, with the picture and reference attached, captioned by those two words, when the entry has that media), the assistant's question, the person's answer, the assistant's plan, the approval line that says the plan and the estimate were reviewed, the person's consent, working (said over the result while it is made) and result (what the result is, in one line, for whoever cannot see it). A visitor is shown their language, else the default language of the pages; an entry without a demo in the visitor's language shows the build's own conversation.
{
  "address": "/gh/owner/repo",
  "width": 752,
  "height": 560,
  "durationMs": 9500,
  "published": "2026-10-01",
  "media": { "poster": "<sha of poster.webp>", "clip": "<sha of clip.mp4>" },
  "texts": {
    "en": {
      "title": "The ad you love, with your star in it.",
      "body": "Show it an ad you admire and a picture of your product.",
      "tags": ["A video and a picture in", "Ready to post"],
      "action": "Make something like this"
    }
  }
}

A repository declares its own picture in its manifest (format). To show another one on a repository's card and page, give its address a picture: an https URL, or an upload from POST /admin/v1/media.

curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: application/json" --data '{"url":"https://example.com/pictures/skills.png"}' https://skills.example.com/admin/v1/images/gh/owner/repo
curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: application/json" --data '{"media":"<sha of banner.webp>"}' https://skills.example.com/admin/v1/images/gh/owner/repo@main/skills/ad

The deployment's own pages take the same shape in miniature. Writing them here keeps their URLs on your own origin, /terms and /privacy, which is what the head, the footer and the consent banner then link to; TERMS_URL and PRIVACY_URL are for pages hosted elsewhere. A visitor reads their language, else the default language of the pages.

curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "content-type: application/json" --data @terms.json https://skills.example.com/admin/v1/legal/terms
{
  "revised": "2026-10-01",
  "texts": {
    "en": { "title": "Terms of service", "body": "## Scope\n\nThese terms govern ..." },
    "ko": { "title": "이용약관", "body": "## 적용 범위\n\n이 약관은 ..." }
  }
}

Signing in and private repositories

Without the variables below a deployment serves public repositories to everyone and knows nobody. With them, people sign in with their GitHub account, open the private repositories GitHub lets them see, and connect their AI apps to them (specs/permissions.md). It needs the web UI (WEB_ROOT, which the image sets) and PUBLIC_URL, over TLS for anything but local development.

Register a GitHub App (not an OAuth app) under the account that operates the deployment:

SettingValue
Homepage URLPUBLIC_URL
Callback URLPUBLIC_URL/auth/gh/callback
Expire user authorization tokensOn. The server renews them.
Request user authorization (OAuth) during installationOff. People sign in from the deployment's own pages; an authorization that starts at GitHub is not one this server asked for and is refused.
Setup URLPUBLIC_URL/account/repositories, with "Redirect on update" on, so that whoever installs the app comes back to the page that lists what they can now open.
WebhookActive, with the URL PUBLIC_URL/webhooks/gh and a secret of your making (openssl rand -base64 48), which is also GITHUB_APP_WEBHOOK_SECRET. Not active: the server receives nothing, and everything it remembers ends when its time is up.
Subscribe to eventsPush and Repository. What the app is told about installations and revoked authorizations comes without subscribing.
Repository permissionsContents: read-only. Metadata: read-only. Nothing else, and no account permissions.
Organization permissionsNone: reading needs no more, and an app should hold nothing it can do without. A change of collaborators, teams or members then takes effect within PERMISSION_TTL_SECONDS. An operator who wants it at once adds Members: read-only, with the events Member, Membership, Team, Team add and Organization, and has to decide that before accounts install the app: a permission added later has to be accepted again by every account that installed it, and does not apply to an installation until it is.
Where can this GitHub App be installed?Any account for a deployment open to others; only this account for a private one.

Then set GITHUB_APP_ID and GITHUB_APP_CLIENT_ID from the app's page, generate a client secret for GITHUB_APP_CLIENT_SECRET and a private key for GITHUB_APP_PRIVATE_KEY, and make up AUTH_SECRET (openssl rand -base64 48). On GitHub Enterprise Server, GITHUB_API_URL names the installation and the app is registered there.

What then happens, and what to know when operating it:

  • A person signs in from the deployment's own pages, and every time GitHub shows its account picker first, so that they see which account they continue with before they are back (ADR-0042). Nothing is configured for it.
  • An owner installs the app on the repositories it may read. A person signs in, and the server asks GitHub, as that person, whether they can see a repository; content is read with the app's installation token, never with a person's. A private repository without the app installed is not found, for its members too.
  • The person's GitHub token is stored encrypted under AUTH_SECRET and used for nothing but asking. Sessions, authorization codes and the tokens of AI apps are stored as hashes. AUTH_SECRET is the one value to guard: keep it with your other secrets, the same on every replica.
  • PERMISSION_TTL_SECONDS is the staleness bound for whatever the git host does not announce. With the webhook active, a push is served by the next request, a repository made private or deleted closes at once, a person who revokes the app is signed out, and taking the app off a repository that is not public removes what was indexed of it. GitHub does not send a failed delivery again by itself: the lifetimes cover a delivery that never arrived, and an operator can send one again from the app's settings for a few days.
  • A delivery only ever ends what is remembered, so the receiver needs no protection beyond the signature: an unsigned request is refused after one hash over at most 4 MiB, and a signed one that is sent twice makes the server ask GitHub again.
  • The deployment is the OAuth authorization server of its own addresses: /.well-known/oauth-authorization-server/oauth, /.well-known/oauth-protected-resource/..., /oauth/* and /auth/* have to reach the server, like /gh/*. Serve nothing of your own at /.well-known/oauth-authorization-server, /.well-known/openid-configuration or /.well-known/oauth-protected-resource: the server leaves them empty on purpose, so that a public address looks to every client like one that needs no sign-in. AI apps register themselves; a registration grants nothing until a person agrees on the consent page. POST /oauth/register is the one place where anyone can write a row: the server keeps the number of unused clients bounded and removes them after a week, and limiting how fast one client can ask is for what you put in front, as for every other anonymous endpoint.
  • A person can make a token for an agent that has nobody to sign in: a scheduled job, a server (ADR-0040). It reads one repository, as that person, until it expires or they remove it, and it is checked against GitHub like every other request of theirs. Nothing is configured for it. A proxy in front has to pass the authorization header of such an agent through, as it does for an app that signed in.
  • To read an app's metadata document the server makes one kind of request to a URL somebody else chose: over TLS, to public addresses only. An installation without outbound internet access still works with the apps that register themselves instead.
  • A process removes expired sessions, codes, tokens and stale answers on its own, every quarter of an hour; nothing has to be scheduled.

Operating a public deployment

Everything above suits a private installation as it comes. A deployment that serves other people's repositories to the public has more to say and to answer for. Before opening one up:

  • Set PUBLIC_URL to the origin visitors use, so that canonical links and the sitemap are yours.
  • Publish terms of service and a privacy policy: write them into the deployment through the admin API, which serves them at /terms and /privacy in every language you write them in, or host them elsewhere and set TERMS_URL and PRIVACY_URL. Set CONTACT_EMAIL to an address that reads takedown requests and content reports. The pages show all three in the footer and carry them in the head. Where people sign in, the button they sign in with says under it that continuing agrees to the terms and the privacy policy, each where you have one, so have them in place before you let people in: the first sign-in is what makes an account.
  • Know what the service does with content, and say it where your visitors can read it: it reads, indexes and serves what a repository publishes, keeps copies only to serve them, and serves or describes a skill by the license it carries (format). A takedown is one entry on the blocked list and one purge through the admin API; keep ADMIN_TOKEN where only the operator can reach it.
  • Decide what you vouch for. A verified repository is served in full on its default branch whatever its license says, and carries no provenance notice: put only repositories there whose owners agreed.
  • Analytics load only after the visitor agrees in the banner the pages show, and never for a browser that signals a privacy preference. Say in your privacy policy what GOOGLE_ANALYTICS_ID sends where, or leave it unset.
  • The access log (ACCESS_LOG) holds client addresses. Give it a retention that your privacy policy states, or turn it off. The usage statistics hold none: set USAGE_HASH_SECRET so that distinct clients count once across replicas, and keep it with your other secrets (ADR-0027).
  • Put TLS, caching and rate limiting in front of the image (below), and a token without scopes in GITHUB_TOKEN so that the anonymous rate limit of the git host is not what your visitors get. The page of an account lists from the git host on behalf of whoever opens it, search engines included, which is one more reason for the token.
  • Where people sign in (above), say in your privacy policy what is kept about them: their account's id, name and login at the git host, the encrypted token, their sessions, which apps they allowed to read which address, and the tokens they made for their agents, as hashes. Signing out ends a session; removing an app ends its access; when GitHub stops vouching for a person, everything of theirs is deleted.

Behind a reverse proxy

The api role speaks plain HTTP and expects TLS, caching and per-client rate limiting to happen in front of it. What the server needs to know about that front:

  • Who the proxies are. List their addresses or networks in TRUSTED_PROXIES. Forwarding headers are believed only when the peer on the socket is on that list; from anyone else they are ignored, because anyone can send them. With x-forwarded-for the client is the nearest address in the chain that is not a trusted proxy. If your proxy passes the client address in a header of its own, name it in CLIENT_IP_HEADER; it is then read as a single address. Make sure the proxy overwrites that header instead of passing on what the client sent.
  • Request ids. A trusted proxy's id (REQUEST_ID_HEADER) is adopted; otherwise the server makes one. Either way it is in every log line of the request and in the x-request-id response header, so a report from a user can be matched to the logs.
  • Idle connections. Keep HTTP_KEEP_ALIVE_SECONDS above the idle timeout of the proxy. When the server closes an idle connection first, the proxy occasionally sends a request into it and answers its client with a gateway error.
  • Slow answers. A tool call may wait up to INDEX_WAIT_MS for an index. The proxy's response timeout has to be longer than that.
  • Streaming. MCP responses may be event streams. Do not buffer or transform text/event-stream responses.
  • Cross-origin calls. The REST API under /api/ and the MCP endpoint under /gh/ answer browsers on any origin by themselves: pass OPTIONS requests and access-control-* headers through.
  • Events. Where the app's webhook is active, POST /webhooks/gh has to reach the server from the git host, with bodies of up to 4 MiB and without anything in front asking for a credential: the server verifies the host's signature itself, over the body exactly as it arrived, so nothing in front may rewrite it.
  • Credentials. Where people sign in, pass cookie, set-cookie, authorization and www-authenticate through untouched, and never cache a response that says private or no-store: the page and the answers of a private repository are one person's. Pass the browser's sec-fetch-site request header through as it came, too: by it the server tells a sign-in begun on its own pages from a link that was followed from somewhere else, which it only offers to sign in (ADR-0041). A /gh/... path answers differently with and without a session, which the response says with its own cache headers rather than with Vary; a cache must revalidate, as the page asks.
  • Pages. With a web UI, set PUBLIC_URL. A page URL with the lang query parameter, and every file, has exactly one representation and may be cached by URL, query string included. A page URL without the parameter is answered in the language the request's Accept-Language asks for, else the default, and says vary: accept-language (ADR-0021): a cache that ignores Vary must not cache it. HTML asks to be revalidated; files under /assets/ never change. Every file answers byte ranges (Accept-Ranges: bytes), which video playback on phones depends on: a cache in front passes Range through, or serves whole files itself. On an address (/gh/...) the response depends on the request: a GET that accepts text/html gets a page rendered with what the address serves, everything else is MCP. The page says vary: accept, and accept-language as well without the parameter, and asks to be revalidated; MCP responses say no-store. A cache that honors both may cache the path; one that ignores Vary must not.
  • Probes. GET /healthz and GET /readyz are not written to the access log.

The access log is one JSON line per request: request id, method, path without its query string, status, duration until the response started, client address and user agent.

Building a release

This repository does not publish images. Whatever deploys the product builds the image itself:

  1. Pin a commit of this repository, preferably one whose CI run is green. main is kept releasable, so any green commit is a candidate.
  2. Build deploy/Dockerfile from the repository root for the platforms you run (docker buildx build --platform linux/arm64 -f deploy/Dockerfile .). The build needs no secrets and no build arguments.
  3. Tag the image with the commit it was built from, keep tags immutable in your registry, and deploy by digest.

The image carries what it is distributed under at /app: LICENSE.md, THIRD-PARTY-NOTICES.md and TRADEMARKS.md; the web build carries the licenses of its bundled packages at /licenses/npm.txt, linked from every page, and the notices of its fonts and icons next to it.

Rollout contract

Whatever rolls the image out must do this, in this order:

  1. Run the new image with the migrate command to completion. A non-zero exit aborts the rollout.
  2. Roll api and worker to the new image. Old and new versions overlap, which is why migrations are backward compatible (see packages/db/CLAUDE.md).
  3. Gate traffic on GET /readyz, restart on GET /healthz failures, and send SIGTERM with a grace period before killing.
  4. Roll back by redeploying the previous digest. Because migrations only expand, the previous version keeps working.

worker tolerates interruption at any time, so it can run on interruptible capacity. api is stateless and scales horizontally behind any load balancer without session affinity.

Repository settings checklist

Set once in GitHub; none of it can be expressed in files here.

  • Branch protection on main is intentionally off for now: maintainers push directly. When the team moves to pull requests, require them together with the CI checks and a linear history.
  • Enable secret scanning with push protection, Dependabot alerts and private vulnerability reporting.
  • Actions: default GITHUB_TOKEN permission read-only; require approval before running workflows from first-time contributors. Allow GitHub Actions to create and approve pull requests (Actions > General): the release workflow opens its version pull request with that.
  • On npm, each package under the @skillcdn scope has this repository registered as its trusted publisher, and then requires two-factor authentication with tokens disallowed. The publisher is GitHub Actions with organization skillcdn, repository skillcdn, workflow filename release.yml (the file name alone, not its path) and no environment name, and its allowed actions include publish. The four fields must match the run exactly: otherwise the registry answers the publish with 404 Not Found. Without the publish action it answers 403 OIDC permission denied for this action, since only staged publishes are allowed by default. Either way the Publish job fails within a second and nothing is published, so the run can be repeated once the publisher is right. A registration is pending until the workflow publishes through it once, and expires after two days unused: a package that saw no release in that time needs it registered again before its next one. The first version of a new package is published by a maintainer by hand, before the publisher can be registered.
  • No cloud credentials, environments or deployment secrets belong to this repository. If a workflow here ever asks for one, that is a mistake.